tickflow-assist 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。
4
4
 
5
+ 最近更新:`v0.2.11` 优化复盘/告警文本样式,接入 PNG 告警卡发送与临时文件清理,并按 A 股习惯调整涨跌主色。
6
+
5
7
  ## 安装
6
8
 
7
9
  社区安装:
package/dist/bootstrap.js CHANGED
@@ -26,6 +26,7 @@ import { QuoteService } from "./services/quote-service.js";
26
26
  import { TradingCalendarService } from "./services/trading-calendar-service.js";
27
27
  import { MonitorService } from "./services/monitor-service.js";
28
28
  import { AlertService } from "./services/alert-service.js";
29
+ import { AlertMediaService } from "./services/alert-media-service.js";
29
30
  import { UpdateService } from "./services/update-service.js";
30
31
  import { KeyLevelsBacktestService } from "./services/key-levels-backtest-service.js";
31
32
  import { PostCloseReviewService } from "./services/post-close-review-service.js";
@@ -106,6 +107,7 @@ export function createAppContext(config, options = {}) {
106
107
  }
107
108
  : undefined,
108
109
  });
110
+ const alertMediaService = new AlertMediaService(config.databasePath);
109
111
  const indicatorService = new IndicatorService(config.pythonBin, config.pythonArgs, config.pythonWorkdir);
110
112
  const watchlistService = new WatchlistService(watchlistRepository, instrumentService, watchlistProfileService);
111
113
  const keyLevelsBacktestService = new KeyLevelsBacktestService(keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, watchlistService);
@@ -121,7 +123,7 @@ export function createAppContext(config, options = {}) {
121
123
  const postCloseReviewTask = new PostCloseReviewTask();
122
124
  const compositeStockAnalysisTask = new CompositeStockAnalysisTask(keyLevelsRepository, analysisLogRepository);
123
125
  const compositeAnalysisOrchestrator = new CompositeAnalysisOrchestrator(analysisService, marketAnalysisProvider, financialAnalysisProvider, newsAnalysisProvider, klineTechnicalSignalTask, financialFundamentalTask, financialFundamentalLiteTask, newsCatalystTask, compositeStockAnalysisTask, technicalAnalysisRepository, financialAnalysisRepository, newsAnalysisRepository, compositeAnalysisRepository);
124
- const monitorService = new MonitorService(config.databasePath, config.requestInterval, config.alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, alertService);
126
+ const monitorService = new MonitorService(config.databasePath, config.requestInterval, config.alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService);
125
127
  const updateService = new UpdateService(klineService, config.tickflowApiKeyLevel, indicatorService, klinesRepository, indicatorsRepository, intradayKlinesRepository, watchlistService, tradingCalendarService);
126
128
  const postCloseReviewService = new PostCloseReviewService(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository);
127
129
  const realtimeMonitorWorker = new RealtimeMonitorWorker(monitorService, config.requestInterval * 1000);
@@ -0,0 +1,30 @@
1
+ export type AlertImageTone = "support" | "breakthrough" | "stop_loss" | "take_profit" | "pressure";
2
+ export interface AlertImagePoint {
3
+ time: string;
4
+ price: number;
5
+ }
6
+ export interface AlertImageLevels {
7
+ stopLoss?: number | null;
8
+ support?: number | null;
9
+ resistance?: number | null;
10
+ breakthrough?: number | null;
11
+ takeProfit?: number | null;
12
+ }
13
+ export interface AlertImageInput {
14
+ tone: AlertImageTone;
15
+ alertLabel: string;
16
+ name: string;
17
+ symbol: string;
18
+ timestampLabel: string;
19
+ currentPrice: number;
20
+ triggerPrice: number;
21
+ changePct?: number | null;
22
+ distancePct?: number | null;
23
+ costPrice?: number | null;
24
+ profitPct?: number | null;
25
+ note: string;
26
+ points: AlertImagePoint[];
27
+ levels: AlertImageLevels;
28
+ }
29
+ export declare function renderAlertCardSvg(input: AlertImageInput): string;
30
+ export declare function renderAlertCardPng(input: AlertImageInput): Promise<Buffer>;
@@ -0,0 +1,435 @@
1
+ import sharp from "sharp";
2
+ const WIDTH = 960;
3
+ const HEIGHT = 640;
4
+ export function renderAlertCardSvg(input) {
5
+ if (input.points.length < 2) {
6
+ throw new Error("alert image requires at least 2 points");
7
+ }
8
+ const theme = resolveTheme(input.tone);
9
+ const direction = resolveMarketDirection(input);
10
+ const directionTheme = resolveDirectionTheme(direction);
11
+ const frame = {
12
+ x: 24,
13
+ y: 24,
14
+ width: 912,
15
+ height: HEIGHT - 48,
16
+ };
17
+ const chart = {
18
+ left: 44,
19
+ top: 214,
20
+ width: 650,
21
+ height: 228,
22
+ };
23
+ const levelPanel = {
24
+ x: 714,
25
+ y: 214,
26
+ width: 202,
27
+ height: 228,
28
+ };
29
+ const rail = {
30
+ left: 60,
31
+ top: 594,
32
+ width: 840,
33
+ };
34
+ const xAxisTextY = chart.top + chart.height + 28;
35
+ const noteTitleY = 500;
36
+ const noteTextY = 526;
37
+ const railTitleY = 562;
38
+ const priceValues = [
39
+ input.currentPrice,
40
+ input.triggerPrice,
41
+ ...input.points.map((point) => point.price),
42
+ input.levels.stopLoss ?? null,
43
+ input.levels.support ?? null,
44
+ input.levels.resistance ?? null,
45
+ input.levels.breakthrough ?? null,
46
+ input.levels.takeProfit ?? null,
47
+ ].filter((value) => value != null && Number.isFinite(value));
48
+ const minValue = Math.min(...priceValues);
49
+ const maxValue = Math.max(...priceValues);
50
+ const padding = Math.max((maxValue - minValue) * 0.18, 0.18);
51
+ const scaledMin = minValue - padding;
52
+ const scaledMax = maxValue + padding;
53
+ const valueRange = Math.max(0.01, scaledMax - scaledMin);
54
+ const scaleX = (index) => {
55
+ if (input.points.length === 1) {
56
+ return chart.left;
57
+ }
58
+ return chart.left + (index / (input.points.length - 1)) * chart.width;
59
+ };
60
+ const scaleY = (value) => (chart.top + ((scaledMax - value) / valueRange) * chart.height);
61
+ const linePath = input.points
62
+ .map((point, index) => `${index === 0 ? "M" : "L"} ${scaleX(index).toFixed(2)} ${scaleY(point.price).toFixed(2)}`)
63
+ .join(" ");
64
+ const areaPath = `${linePath} L ${(chart.left + chart.width).toFixed(2)} ${(chart.top + chart.height).toFixed(2)} L ${chart.left.toFixed(2)} ${(chart.top + chart.height).toFixed(2)} Z`;
65
+ const currentX = scaleX(input.points.length - 1);
66
+ const currentY = scaleY(input.points[input.points.length - 1]?.price ?? input.currentPrice);
67
+ const horizontalGrid = Array.from({ length: 5 }, (_, index) => {
68
+ const y = chart.top + (index / 4) * chart.height;
69
+ const value = scaledMax - (index / 4) * valueRange;
70
+ return {
71
+ y,
72
+ value,
73
+ };
74
+ });
75
+ const timeMarkers = buildTimeMarkers(input.points, scaleX);
76
+ const levelEntries = buildLevelEntries(input);
77
+ const levelPanelEntries = [...levelEntries].sort((left, right) => right.value - left.value);
78
+ const levelLines = buildLevelLines(levelEntries, scaleY);
79
+ const railMarkers = buildRailMarkers(input, rail.left, rail.width, rail.top, scaledMin, scaledMax);
80
+ const metricLines = buildMetricLines(input);
81
+ return `<?xml version="1.0" encoding="UTF-8"?>
82
+ <svg width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" fill="none" xmlns="http://www.w3.org/2000/svg">
83
+ <defs>
84
+ <linearGradient id="bg" x1="0" y1="0" x2="${WIDTH}" y2="${HEIGHT}" gradientUnits="userSpaceOnUse">
85
+ <stop stop-color="${directionTheme.backgroundStart}"/>
86
+ <stop offset="0.55" stop-color="${directionTheme.backgroundMid}"/>
87
+ <stop offset="1" stop-color="${directionTheme.backgroundEnd}"/>
88
+ </linearGradient>
89
+ <linearGradient id="chartFill" x1="${chart.left}" y1="${chart.top}" x2="${chart.left}" y2="${chart.top + chart.height}" gradientUnits="userSpaceOnUse">
90
+ <stop stop-color="${theme.accentSoft}" stop-opacity="0.55"/>
91
+ <stop offset="1" stop-color="${theme.accentSoft}" stop-opacity="0"/>
92
+ </linearGradient>
93
+ <linearGradient id="chartStroke" x1="${chart.left}" y1="${chart.top}" x2="${chart.left + chart.width}" y2="${chart.top}" gradientUnits="userSpaceOnUse">
94
+ <stop stop-color="${theme.accent}"/>
95
+ <stop offset="1" stop-color="${theme.accentStrong}"/>
96
+ </linearGradient>
97
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
98
+ <feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#02060C" flood-opacity="0.42"/>
99
+ </filter>
100
+ </defs>
101
+
102
+ <rect width="${WIDTH}" height="${HEIGHT}" rx="28" fill="url(#bg)"/>
103
+ <rect width="${WIDTH}" height="${HEIGHT}" rx="28" fill="${directionTheme.glowSoft}" fill-opacity="0.16"/>
104
+ <circle cx="842" cy="96" r="164" fill="${directionTheme.glowStrong}" fill-opacity="0.26"/>
105
+ <circle cx="760" cy="430" r="192" fill="${directionTheme.glowSoft}" fill-opacity="0.18"/>
106
+ <circle cx="120" cy="520" r="208" fill="${theme.accentSoft}" fill-opacity="0.12"/>
107
+
108
+ <rect x="${frame.x}" y="${frame.y}" width="${frame.width}" height="${frame.height}" rx="24" fill="${directionTheme.panelFill}" fill-opacity="0.90" stroke="${directionTheme.frameStroke}" stroke-width="1.4" stroke-opacity="0.95"/>
109
+ <rect x="${frame.x}" y="${frame.y}" width="${frame.width}" height="12" rx="24" fill="${directionTheme.ribbon}"/>
110
+
111
+ <text x="48" y="58" fill="#88A2BF" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif" letter-spacing="1.2">TICKFLOW ALERT PREVIEW</text>
112
+ <text x="48" y="108" fill="#F4F8FC" font-size="34" font-weight="700" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(input.name)}</text>
113
+ <text x="48" y="136" fill="#8FA8C4" font-size="18" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(input.symbol)} | ${escapeXml(input.timestampLabel)}</text>
114
+ <rect x="48" y="150" width="122" height="30" rx="15" fill="${directionTheme.marketPillFill}"/>
115
+ <text x="109" y="170" text-anchor="middle" fill="${directionTheme.marketPillText}" font-size="14" font-weight="700" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(directionTheme.marketLabel)}</text>
116
+
117
+ <rect x="710" y="42" width="198" height="42" rx="21" fill="${theme.signalPillFill}" />
118
+ <text x="809" y="69" text-anchor="middle" fill="${theme.signalPillText}" font-size="18" font-weight="700" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(input.alertLabel)}</text>
119
+
120
+ <text x="718" y="118" fill="#8AA3BE" font-size="15" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">当前价</text>
121
+ <text x="718" y="156" fill="#F6FBFF" font-size="34" font-weight="800" font-family="'JetBrains Mono','SFMono-Regular','Consolas',monospace">${input.currentPrice.toFixed(2)}</text>
122
+ ${metricLines.map((line, index) => `
123
+ <text x="718" y="${180 + index * 18}" fill="#A8BED7" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(line)}</text>`).join("")}
124
+
125
+ <rect x="${chart.left}" y="${chart.top}" width="${chart.width}" height="${chart.height}" rx="18" fill="${directionTheme.chartPanelFill}" stroke="${theme.panelBorder}" stroke-opacity="0.9"/>
126
+ ${horizontalGrid.map((line) => `
127
+ <line x1="${chart.left}" y1="${line.y.toFixed(2)}" x2="${chart.left + chart.width}" y2="${line.y.toFixed(2)}" stroke="#213247" stroke-dasharray="4 8"/>
128
+ <text x="${chart.left + 12}" y="${(line.y - 8).toFixed(2)}" fill="#6E88A5" font-size="12" font-family="'JetBrains Mono','SFMono-Regular','Consolas',monospace">${line.value.toFixed(2)}</text>`).join("")}
129
+
130
+ ${levelLines.map((line) => `
131
+ <line x1="${chart.left}" y1="${line.lineY.toFixed(2)}" x2="${chart.left + chart.width}" y2="${line.lineY.toFixed(2)}" stroke="${line.stroke}" stroke-width="${line.width}" stroke-dasharray="${line.dasharray}"/>`).join("")}
132
+
133
+ <path d="${areaPath}" fill="url(#chartFill)"/>
134
+ <path d="${linePath}" stroke="url(#chartStroke)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)"/>
135
+ <circle cx="${currentX.toFixed(2)}" cy="${currentY.toFixed(2)}" r="7" fill="${theme.accentStrong}" stroke="#F4FBFF" stroke-width="3"/>
136
+
137
+ <rect x="${levelPanel.x}" y="${levelPanel.y}" width="${levelPanel.width}" height="${levelPanel.height}" rx="18" fill="${directionTheme.levelPanelFill}" stroke="${theme.panelBorder}" stroke-opacity="0.9"/>
138
+ <text x="${levelPanel.x + 18}" y="${levelPanel.y + 28}" fill="#8EA7C1" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">关键价位</text>
139
+ ${levelPanelEntries.map((entry, index) => {
140
+ const rowY = levelPanel.y + 60 + index * 34;
141
+ return `
142
+ <line x1="${levelPanel.x + 18}" y1="${rowY}" x2="${levelPanel.x + 42}" y2="${rowY}" stroke="${entry.stroke}" stroke-width="${entry.width + 0.5}" stroke-dasharray="${entry.dasharray}"/>
143
+ <text x="${levelPanel.x + 54}" y="${rowY + 5}" fill="#DCE8F5" font-size="14" font-weight="600" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(entry.label)}</text>
144
+ <text x="${levelPanel.x + levelPanel.width - 18}" y="${rowY + 5}" text-anchor="end" fill="${entry.text}" font-size="14" font-weight="700" font-family="'JetBrains Mono','SFMono-Regular','Consolas',monospace">${entry.value.toFixed(2)}</text>`;
145
+ }).join("")}
146
+
147
+ ${timeMarkers.map((marker) => `
148
+ <line x1="${marker.x.toFixed(2)}" y1="${chart.top + chart.height}" x2="${marker.x.toFixed(2)}" y2="${chart.top + chart.height + 8}" stroke="#48627E"/>
149
+ <text x="${marker.x.toFixed(2)}" y="${xAxisTextY}" text-anchor="middle" fill="#7791AD" font-size="12" font-family="'JetBrains Mono','SFMono-Regular','Consolas',monospace">${escapeXml(marker.label)}</text>`).join("")}
150
+
151
+ <text x="48" y="${noteTitleY}" fill="#8EA7C1" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">告警说明</text>
152
+ <text x="48" y="${noteTextY}" fill="#E8F1F8" font-size="16" font-weight="600" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(input.note)}</text>
153
+
154
+ <text x="48" y="${railTitleY}" fill="#8EA7C1" font-size="14" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">位阶带</text>
155
+ <line x1="${rail.left}" y1="${rail.top}" x2="${rail.left + rail.width}" y2="${rail.top}" stroke="#2E445D" stroke-width="8" stroke-linecap="round"/>
156
+ ${railMarkers.map((marker) => `
157
+ <line x1="${marker.x.toFixed(2)}" y1="${(rail.top - 14).toFixed(2)}" x2="${marker.x.toFixed(2)}" y2="${(rail.top + 14).toFixed(2)}" stroke="${marker.stroke}" stroke-width="${marker.width}" stroke-linecap="round"/>
158
+ <text x="${marker.x.toFixed(2)}" y="${marker.textY.toFixed(2)}" text-anchor="middle" fill="${marker.text}" font-size="12" font-weight="700" font-family="'Noto Sans CJK SC','Microsoft YaHei','PingFang SC',sans-serif">${escapeXml(marker.label)}</text>`).join("")}
159
+ </svg>`;
160
+ }
161
+ export async function renderAlertCardPng(input) {
162
+ const svg = renderAlertCardSvg(input);
163
+ return sharp(Buffer.from(svg)).png().toBuffer();
164
+ }
165
+ function resolveMarketDirection(input) {
166
+ const basis = input.changePct ?? (((input.points[input.points.length - 1]?.price ?? input.currentPrice)
167
+ - (input.points[0]?.price ?? input.currentPrice))
168
+ / Math.max(0.01, input.points[0]?.price ?? input.currentPrice)) * 100;
169
+ if (basis > 0.01) {
170
+ return "up";
171
+ }
172
+ if (basis < -0.01) {
173
+ return "down";
174
+ }
175
+ return "flat";
176
+ }
177
+ function resolveTheme(tone) {
178
+ switch (tone) {
179
+ case "breakthrough":
180
+ return {
181
+ accent: "#67F3AE",
182
+ accentSoft: "#2AD97C",
183
+ accentStrong: "#9CFFCB",
184
+ panelBorder: "#2B7251",
185
+ priceTagFill: "#155A3D",
186
+ signalPillFill: "#163F2D",
187
+ signalPillText: "#BDF6D8",
188
+ };
189
+ case "stop_loss":
190
+ return {
191
+ accent: "#FF7C7C",
192
+ accentSoft: "#F55050",
193
+ accentStrong: "#FFC6C6",
194
+ panelBorder: "#7D3131",
195
+ priceTagFill: "#672727",
196
+ signalPillFill: "#471F1F",
197
+ signalPillText: "#FFD4D4",
198
+ };
199
+ case "take_profit":
200
+ return {
201
+ accent: "#D19BFF",
202
+ accentSoft: "#9D5CF2",
203
+ accentStrong: "#E6CCFF",
204
+ panelBorder: "#6B4398",
205
+ priceTagFill: "#4D2F71",
206
+ signalPillFill: "#38214F",
207
+ signalPillText: "#EDD7FF",
208
+ };
209
+ case "pressure":
210
+ return {
211
+ accent: "#FFC56A",
212
+ accentSoft: "#F19E2E",
213
+ accentStrong: "#FFE0A6",
214
+ panelBorder: "#8B6130",
215
+ priceTagFill: "#62441F",
216
+ signalPillFill: "#4A331A",
217
+ signalPillText: "#FFE3B8",
218
+ };
219
+ default:
220
+ return {
221
+ accent: "#6AD4FF",
222
+ accentSoft: "#2F8DFF",
223
+ accentStrong: "#B7ECFF",
224
+ panelBorder: "#285A8D",
225
+ priceTagFill: "#1D4F81",
226
+ signalPillFill: "#183957",
227
+ signalPillText: "#D0F2FF",
228
+ };
229
+ }
230
+ }
231
+ function resolveDirectionTheme(direction) {
232
+ switch (direction) {
233
+ case "up":
234
+ return {
235
+ backgroundStart: "#33080D",
236
+ backgroundMid: "#4C0F17",
237
+ backgroundEnd: "#29070C",
238
+ glowStrong: "#FF5D73",
239
+ glowSoft: "#BD2E49",
240
+ ribbon: "#FF6B81",
241
+ frameStroke: "#FF7488",
242
+ marketPillFill: "#5A1F2A",
243
+ marketPillText: "#FFE3E7",
244
+ marketLabel: "日内上涨",
245
+ panelFill: "#1D0C10",
246
+ chartPanelFill: "#271116",
247
+ levelPanelFill: "#2B1218",
248
+ };
249
+ case "down":
250
+ return {
251
+ backgroundStart: "#0A2B19",
252
+ backgroundMid: "#114124",
253
+ backgroundEnd: "#082214",
254
+ glowStrong: "#30F289",
255
+ glowSoft: "#10A85A",
256
+ ribbon: "#42F79C",
257
+ frameStroke: "#49ED98",
258
+ marketPillFill: "#195334",
259
+ marketPillText: "#D9FFE9",
260
+ marketLabel: "日内下跌",
261
+ panelFill: "#071A10",
262
+ chartPanelFill: "#0A2317",
263
+ levelPanelFill: "#0C2519",
264
+ };
265
+ default:
266
+ return {
267
+ backgroundStart: "#081730",
268
+ backgroundMid: "#0C2144",
269
+ backgroundEnd: "#08162B",
270
+ glowStrong: "#55AFFF",
271
+ glowSoft: "#2767B1",
272
+ ribbon: "#49A5FF",
273
+ frameStroke: "#5EB0FF",
274
+ marketPillFill: "#18456F",
275
+ marketPillText: "#DBF1FF",
276
+ marketLabel: "日内走平",
277
+ panelFill: "#091427",
278
+ chartPanelFill: "#0D1B35",
279
+ levelPanelFill: "#0F203C",
280
+ };
281
+ }
282
+ }
283
+ function buildMetricLines(input) {
284
+ const parts = [
285
+ `触发位 ${input.triggerPrice.toFixed(2)}`,
286
+ input.changePct == null ? null : `当日 ${formatSignedPercent(input.changePct)}`,
287
+ input.distancePct == null ? null : `偏离 ${formatSignedPercent(input.distancePct)}`,
288
+ input.profitPct == null ? null : `持仓 ${formatSignedPercent(input.profitPct)}`,
289
+ ].filter((value) => Boolean(value));
290
+ if (parts.length <= 2) {
291
+ return [parts.join(" | ")];
292
+ }
293
+ return [parts.slice(0, 2).join(" | "), parts.slice(2).join(" | ")];
294
+ }
295
+ function buildLevelEntries(input) {
296
+ return [
297
+ buildLevelEntry("止损", input.levels.stopLoss, "#FF6A6A", "rgba(92,30,35,0.94)", "#FFD3D3", 2.5, "6 6"),
298
+ buildLevelEntry("支撑", input.levels.support, "#78C7FF", "rgba(27,69,110,0.94)", "#DDF4FF", 2.5, "6 6"),
299
+ buildLevelEntry("压力", input.levels.resistance, "#FFCC66", "rgba(93,69,20,0.94)", "#FFF0C7", 2.5, "6 6"),
300
+ buildLevelEntry("突破", input.levels.breakthrough, "#7EF0B2", "rgba(22,74,49,0.94)", "#D9FFE9", 2.5, "6 6"),
301
+ buildLevelEntry("止盈", input.levels.takeProfit, "#D6A4FF", "rgba(76,43,117,0.94)", "#F1DFFF", 2.5, "6 6"),
302
+ ].filter((entry) => Boolean(entry));
303
+ }
304
+ function buildLevelLines(entries, scaleY) {
305
+ return entries.map((entry) => ({
306
+ lineY: scaleY(entry.value),
307
+ stroke: entry.stroke,
308
+ width: entry.width,
309
+ dasharray: entry.dasharray,
310
+ }));
311
+ }
312
+ function buildLevelEntry(label, value, stroke, fill, text, width, dasharray) {
313
+ if (!(value != null && Number.isFinite(value))) {
314
+ return null;
315
+ }
316
+ return {
317
+ value,
318
+ stroke,
319
+ fill,
320
+ text,
321
+ width,
322
+ dasharray,
323
+ label,
324
+ };
325
+ }
326
+ function buildTimeMarkers(points, scaleX) {
327
+ const preferred = ["09:30", "10:30", "11:30", "13:00", "14:00", "15:00"];
328
+ const markers = [];
329
+ for (const label of preferred) {
330
+ const exactIndex = points.findIndex((point) => point.time.startsWith(label));
331
+ if (exactIndex >= 0) {
332
+ markers.push({
333
+ x: scaleX(exactIndex),
334
+ label,
335
+ });
336
+ }
337
+ }
338
+ if (markers.length >= 3) {
339
+ return filterNearbyMarkers(markers, 56);
340
+ }
341
+ return filterNearbyMarkers([
342
+ { x: scaleX(0), label: points[0]?.time.slice(0, 5) ?? "" },
343
+ { x: scaleX(Math.floor((points.length - 1) / 2)), label: points[Math.floor((points.length - 1) / 2)]?.time.slice(0, 5) ?? "" },
344
+ { x: scaleX(points.length - 1), label: points[points.length - 1]?.time.slice(0, 5) ?? "" },
345
+ ], 56);
346
+ }
347
+ function buildRailMarkers(input, left, width, top, minValue, maxValue) {
348
+ const range = Math.max(0.01, maxValue - minValue);
349
+ const rawMarkers = [
350
+ { label: "止损", value: input.levels.stopLoss, stroke: "#FF7373", width: 5, text: "#FFCFCF" },
351
+ { label: "支撑", value: input.levels.support, stroke: "#74CFFF", width: 5, text: "#DBF4FF" },
352
+ { label: "现价", value: input.currentPrice, stroke: "#F7FBFF", width: 6, text: "#F7FBFF" },
353
+ { label: "压力", value: input.levels.resistance, stroke: "#FFCC6D", width: 5, text: "#FFF0C7" },
354
+ { label: "突破", value: input.levels.breakthrough, stroke: "#7DF2B4", width: 5, text: "#D8FFE8" },
355
+ { label: "止盈", value: input.levels.takeProfit, stroke: "#D9ABFF", width: 5, text: "#F2E2FF" },
356
+ ].filter((marker) => (marker.value != null && Number.isFinite(marker.value)));
357
+ const groupedMarkers = groupRailMarkers(rawMarkers).sort((leftMarker, rightMarker) => leftMarker.value - rightMarker.value);
358
+ const lanes = [
359
+ { y: top - 22, lastRight: -Infinity },
360
+ { y: Math.min(HEIGHT - 16, top + 38), lastRight: -Infinity },
361
+ { y: top - 46, lastRight: -Infinity },
362
+ ];
363
+ let previousX = -Infinity;
364
+ return groupedMarkers.map((marker) => {
365
+ const rawX = left + ((marker.value - minValue) / range) * width;
366
+ const x = Math.max(previousX + 22, Math.min(left + width, rawX));
367
+ const labelWidth = estimateRailLabelWidth(marker.label);
368
+ const preferredLane = lanes.find((lane) => x - labelWidth / 2 >= lane.lastRight + 10)
369
+ ?? lanes.reduce((best, lane) => (lane.lastRight < best.lastRight ? lane : best), lanes[0]);
370
+ preferredLane.lastRight = x + labelWidth / 2;
371
+ previousX = x;
372
+ return {
373
+ x,
374
+ label: marker.label,
375
+ stroke: marker.stroke,
376
+ width: marker.width,
377
+ text: marker.text,
378
+ textY: preferredLane.y,
379
+ };
380
+ });
381
+ }
382
+ function estimateRailLabelWidth(label) {
383
+ return label.length * 7 + 18;
384
+ }
385
+ function groupRailMarkers(markers) {
386
+ const groups = new Map();
387
+ for (const marker of markers) {
388
+ const key = marker.value.toFixed(4);
389
+ const group = groups.get(key) ?? [];
390
+ group.push(marker);
391
+ groups.set(key, group);
392
+ }
393
+ return [...groups.values()].map((group) => {
394
+ const label = `${group.map((item) => item.label).join("/")} ${group[0].value.toFixed(2)}`;
395
+ if (group.length === 1) {
396
+ return {
397
+ label,
398
+ value: group[0].value,
399
+ stroke: group[0].stroke,
400
+ width: group[0].width,
401
+ text: group[0].text,
402
+ };
403
+ }
404
+ return {
405
+ label,
406
+ value: group[0].value,
407
+ stroke: "#F3F7FB",
408
+ width: 6,
409
+ text: "#F7FBFF",
410
+ };
411
+ });
412
+ }
413
+ function formatSignedPercent(value) {
414
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`;
415
+ }
416
+ function escapeXml(value) {
417
+ return value
418
+ .replace(/&/g, "&amp;")
419
+ .replace(/</g, "&lt;")
420
+ .replace(/>/g, "&gt;")
421
+ .replace(/"/g, "&quot;")
422
+ .replace(/'/g, "&apos;");
423
+ }
424
+ function filterNearbyMarkers(markers, minGap) {
425
+ const filtered = [];
426
+ let previousX = -Infinity;
427
+ for (const marker of markers) {
428
+ if (marker.x - previousX < minGap) {
429
+ continue;
430
+ }
431
+ filtered.push(marker);
432
+ previousX = marker.x;
433
+ }
434
+ return filtered;
435
+ }
@@ -0,0 +1,23 @@
1
+ import type { AlertImageInput } from "./alert-image-service.js";
2
+ export interface AlertMediaFile {
3
+ filePath: string;
4
+ filename: string;
5
+ mediaLocalRoots: readonly string[];
6
+ }
7
+ export declare class AlertMediaService {
8
+ private readonly baseDir;
9
+ private readonly retentionHours;
10
+ private readonly cleanupIntervalMs;
11
+ private lastCleanupAt;
12
+ constructor(baseDir: string, retentionHours?: number, cleanupIntervalMs?: number);
13
+ getTempRootDir(): string;
14
+ writeAlertCard(params: {
15
+ symbol: string;
16
+ ruleName: string;
17
+ image: AlertImageInput;
18
+ }): Promise<AlertMediaFile>;
19
+ removeFile(filePath: string): Promise<void>;
20
+ maybeCleanupExpired(nowMs?: number): Promise<void>;
21
+ private cleanupDirectory;
22
+ private removeEmptyParents;
23
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdir, readdir, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { renderAlertCardPng } from "./alert-image-service.js";
4
+ import { formatChinaDateTime } from "../utils/china-time.js";
5
+ const DEFAULT_RETENTION_HOURS = 24;
6
+ const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
7
+ export class AlertMediaService {
8
+ baseDir;
9
+ retentionHours;
10
+ cleanupIntervalMs;
11
+ lastCleanupAt = 0;
12
+ constructor(baseDir, retentionHours = DEFAULT_RETENTION_HOURS, cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS) {
13
+ this.baseDir = baseDir;
14
+ this.retentionHours = retentionHours;
15
+ this.cleanupIntervalMs = cleanupIntervalMs;
16
+ }
17
+ getTempRootDir() {
18
+ return path.resolve(this.baseDir, "..", "alert-media", "tmp");
19
+ }
20
+ async writeAlertCard(params) {
21
+ await this.maybeCleanupExpired();
22
+ const now = formatChinaDateTime();
23
+ const dateDir = now.slice(0, 10);
24
+ const outputDir = path.join(this.getTempRootDir(), dateDir);
25
+ await mkdir(outputDir, { recursive: true });
26
+ const filename = [
27
+ sanitizeFilePart(now.replace(/[: ]/g, "-")),
28
+ sanitizeFilePart(params.symbol),
29
+ sanitizeFilePart(params.ruleName),
30
+ ].join("_") + ".png";
31
+ const filePath = path.join(outputDir, filename);
32
+ const png = await renderAlertCardPng(params.image);
33
+ await writeFile(filePath, png);
34
+ return {
35
+ filePath,
36
+ filename,
37
+ mediaLocalRoots: [outputDir],
38
+ };
39
+ }
40
+ async removeFile(filePath) {
41
+ try {
42
+ await unlink(filePath);
43
+ }
44
+ catch (error) {
45
+ if (error.code !== "ENOENT") {
46
+ throw error;
47
+ }
48
+ }
49
+ await this.removeEmptyParents(path.dirname(filePath));
50
+ }
51
+ async maybeCleanupExpired(nowMs = Date.now()) {
52
+ if (nowMs - this.lastCleanupAt < this.cleanupIntervalMs) {
53
+ return;
54
+ }
55
+ this.lastCleanupAt = nowMs;
56
+ const cutoffMs = nowMs - this.retentionHours * 60 * 60 * 1000;
57
+ await this.cleanupDirectory(this.getTempRootDir(), cutoffMs);
58
+ }
59
+ async cleanupDirectory(dir, cutoffMs) {
60
+ let entries;
61
+ try {
62
+ entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" });
63
+ }
64
+ catch (error) {
65
+ if (error.code === "ENOENT") {
66
+ return true;
67
+ }
68
+ throw error;
69
+ }
70
+ for (const entry of entries) {
71
+ const fullPath = path.join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ const empty = await this.cleanupDirectory(fullPath, cutoffMs);
74
+ if (empty) {
75
+ await safeRemoveDir(fullPath);
76
+ }
77
+ continue;
78
+ }
79
+ const fileStat = await stat(fullPath);
80
+ if (fileStat.mtimeMs < cutoffMs) {
81
+ await unlink(fullPath);
82
+ }
83
+ }
84
+ const remaining = await readdir(dir);
85
+ if (remaining.length === 0 && dir !== this.getTempRootDir()) {
86
+ await safeRemoveDir(dir);
87
+ return true;
88
+ }
89
+ return remaining.length === 0;
90
+ }
91
+ async removeEmptyParents(dir) {
92
+ const root = this.getTempRootDir();
93
+ let current = dir;
94
+ while (current.startsWith(root) && current !== root) {
95
+ let remaining;
96
+ try {
97
+ remaining = await readdir(current);
98
+ }
99
+ catch (error) {
100
+ if (error.code === "ENOENT") {
101
+ current = path.dirname(current);
102
+ continue;
103
+ }
104
+ throw error;
105
+ }
106
+ if (remaining.length > 0) {
107
+ return;
108
+ }
109
+ await safeRemoveDir(current);
110
+ current = path.dirname(current);
111
+ }
112
+ }
113
+ }
114
+ async function safeRemoveDir(dir) {
115
+ try {
116
+ await rmdir(dir);
117
+ }
118
+ catch (error) {
119
+ const code = error.code;
120
+ if (code !== "ENOENT" && code !== "ENOTEMPTY") {
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+ function sanitizeFilePart(value) {
126
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
127
+ }