tickflow-assist 0.2.10 → 0.2.12

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.12` 调整社区安装清单,允许先安装插件再执行 `configure-openclaw` 写入密钥配置;同时将 `test_alert` 升级为文本 + PNG 告警卡链路测试。
6
+
5
7
  ## 安装
6
8
 
7
9
  社区安装:
@@ -11,6 +13,8 @@ openclaw plugins install tickflow-assist
11
13
  npx -y tickflow-assist configure-openclaw
12
14
  ```
13
15
 
16
+ 安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey` 等正式配置。
17
+
14
18
  第二条命令会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries["tickflow-assist"].config`,并默认执行:
15
19
 
16
20
  - `openclaw plugins enable tickflow-assist`
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);
@@ -150,7 +152,7 @@ export function createAppContext(config, options = {}) {
150
152
  startMonitorTool(monitorService, runtime),
151
153
  stopDailyUpdateTool(dailyUpdateWorker, runtime),
152
154
  stopMonitorTool(monitorService, runtime),
153
- testAlertTool(alertService),
155
+ testAlertTool(alertService, alertMediaService),
154
156
  updateAllTool(dailyUpdateWorker),
155
157
  viewAnalysisTool(analysisViewService),
156
158
  ],
@@ -208,7 +208,7 @@ export function registerPluginCommands(api, tools, app) {
208
208
  },
209
209
  {
210
210
  name: "ta_testalert",
211
- description: "发送一条测试告警,不经过 AI 对话。",
211
+ description: "发送一条文本 + PNG 测试告警,不经过 AI 对话。",
212
212
  requireAuth: true,
213
213
  handler: async () => ({
214
214
  text: await runToolText(testAlert),
@@ -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
+ }