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 +4 -0
- package/dist/bootstrap.js +4 -2
- package/dist/plugin-commands.js +1 -1
- package/dist/services/alert-image-service.d.ts +30 -0
- package/dist/services/alert-image-service.js +435 -0
- package/dist/services/alert-media-service.d.ts +23 -0
- package/dist/services/alert-media-service.js +127 -0
- package/dist/services/alert-service.d.ts +23 -2
- package/dist/services/alert-service.js +176 -40
- package/dist/services/monitor-service.d.ts +9 -1
- package/dist/services/monitor-service.js +213 -17
- package/dist/services/post-close-review-service.d.ts +4 -0
- package/dist/services/post-close-review-service.js +136 -40
- package/dist/tools/test-alert.tool.d.ts +2 -1
- package/dist/tools/test-alert.tool.js +92 -12
- package/openclaw.plugin.json +5 -6
- package/package.json +4 -3
- package/skills/usage-help/SKILL.md +1 -0
- package/dist/plugin-registration.test.d.ts +0 -1
- package/dist/plugin-registration.test.js +0 -93
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
|
],
|
package/dist/plugin-commands.js
CHANGED
|
@@ -208,7 +208,7 @@ export function registerPluginCommands(api, tools, app) {
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
name: "ta_testalert",
|
|
211
|
-
description: "
|
|
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, "&")
|
|
419
|
+
.replace(/</g, "<")
|
|
420
|
+
.replace(/>/g, ">")
|
|
421
|
+
.replace(/"/g, """)
|
|
422
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|