Compare commits
30 Commits
6f846a0a3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3251831118 | |||
| 219a850d93 | |||
| 4d4dbd27b2 | |||
| b396638438 | |||
| f8e74738cc | |||
| 6f5aaa9f85 | |||
| c7e154d7cc | |||
| cd3db8d453 | |||
| 7651c57536 | |||
| 5eebddc680 | |||
| 155a54f536 | |||
| f7ea2e7cf9 | |||
| 33b2a96bcf | |||
| a2c0590353 | |||
| a93078dc00 | |||
| d2e8476398 | |||
| f5f64f7653 | |||
| 0f9f154969 | |||
| b3c42af53b | |||
| 14504af950 | |||
| a9b6234208 | |||
| ae25409e56 | |||
| de7a52e81f | |||
| dc313fda13 | |||
| d6c416a543 | |||
| 0210b70bda | |||
| 6bdd3eb786 | |||
| 4fe9f83b89 | |||
| 7a7e69c69a | |||
| ab295d6d28 |
@@ -95,12 +95,14 @@ make build && ./bin/server
|
||||
| `smtp.*` | 邮件发送(email 渠道) | — |
|
||||
| `rate_limit.default` | 每 source 每秒请求上限 | `100` |
|
||||
| `rate_limit.dingtalk_per_min` | 同一钉钉机器人(access_token)每分钟发送上限;超限排队到下一分钟 | `18`(官方 20,留余量) |
|
||||
| `subscription_dedup_ttl` | 多队列重复消息(body SHA-256)去重窗口 | `1h` |
|
||||
| `subscription_dedup_ttl` | 多队列重复消息(来源/策略/币种/周期/方向/动作/价格)去重窗口 | `1h` |
|
||||
| `subscriptions` | RabbitMQ 订阅列表;某条 `url` 为空则跳过 | 空 |
|
||||
| `subscriptions[].source` | 对应已有 Source.name | 有 url 时必填 |
|
||||
| `subscriptions[].formatter` | 目前仅 `trade_signal` | `trade_signal` |
|
||||
|
||||
环境变量 `RABBITMQ_URL` 未设置时不启动消费,HTTP 通知不受影响。交易信号订阅需事先创建 Source(如 `trade-signal`)、模板(可用 `{{.formatted}}`)、规则 `trade.open` / `trade.add` / `trade.close` / `trade.reduce`、以及渠道。规则条件可用 `strategyCode` / `symbol` / `period`。
|
||||
环境变量 `RABBITMQ_URL` 未设置时不启动消费,HTTP 通知不受影响。交易信号订阅需事先创建 Source(如 `trade-signal`)、模板(可用 `{{.formatted}}`)、规则 `trade.open` / `trade.add` / `trade.close` / `trade.reduce`、以及渠道。规则条件可用 `strategyCode` / `symbol` / `period`。跟单策略(B龙 `BLONG` 及后续)复用模板 `跟单策略`,规则 `trade.*` + `strategyCode`。巴菲特激进 `PUTEJJ` / 稳健 `PUTEWJ` 用模板 `巴菲特策略`(`rawMessage`),发到巴菲特三群,并过滤含「启动」的文案。
|
||||
|
||||
`crypto-strategy` 开仓(多/空,含原来的 `isSale` 空单)都映射为 `trade.open`,不再发 `trade.sell`。止盈(`isGain`)为 `trade.gain`,止损(`isClose` 且非 `isGain`)为 `trade.close`。高低分 `HLSS`、异动 `AMA`、波段 `BTS`、AG 趋势 `AGTS` 用各自前缀:`HLSS.open` / `AMA.open` / `BTS.close` / `AGTS.open` 等。同一 Source 允许多条相同 event 的规则(用条件区分);精确 event 优先于通配,条件通过的规则都会发送。现成模板与规则见 `docs/httpie/curls.md`。
|
||||
|
||||
健康检查:`GET /health` → `{"status":"ok"}`
|
||||
|
||||
@@ -299,13 +301,30 @@ Body 同创建。成功:`{"ok": true}`
|
||||
前缀:`/api/v1/templates`
|
||||
**鉴权:** Admin Key
|
||||
|
||||
模板语法为 Go `text/template`,变量来自解析后的 data。
|
||||
模板语法为 Go `text/template`,变量来自解析后的 data。缺字段不再报错,按空值处理。
|
||||
|
||||
可用 `line` 把前缀和值包在一起:值为空则整行不输出(含前缀和换行)。
|
||||
|
||||
`case` 类似 switch:按值匹配成对的 key/文案,最后一个奇数参数是默认值。`trade.close` 能匹配 `.close` / `close` / `CLOSE`。
|
||||
|
||||
`replace` 做全文替换:`{{replace .rawMessage "Time:" "推送时间:"}}`。
|
||||
|
||||
```
|
||||
### {{.symbol}} {{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈" "SELL" "卖出" "ADD" "加仓" "REDUCE" "减仓"}}
|
||||
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}{{line "推送时间" .pushedAt}}
|
||||
```
|
||||
|
||||
渲染时会自动注入 `event`、`pushedAt`(UTC+8,`2006.01.02 15:04:05`)。
|
||||
|
||||
也可用事件名:`{{case .event ".open" "开仓" ".close" "平仓"}}`
|
||||
|
||||
等价写法:`{{with .totalAvgPx}}平均价:{{.}}{{end}}`
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
### {{.symbol}} 开仓
|
||||
价格: {{.price}}
|
||||
{{line "价格" .price}}
|
||||
```
|
||||
|
||||
Text 模式示例:`{{.Body}}`
|
||||
@@ -369,12 +388,12 @@ Query:`page`、`page_size`(默认同 sources)。**200:** `{ "data": Temp
|
||||
|
||||
```json
|
||||
{
|
||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"access_token": "xxx",
|
||||
"secret": "SEC..."
|
||||
}
|
||||
```
|
||||
|
||||
`secret` 可选(加签机器人时填写)。消息类型:markdown。
|
||||
`secret` 可选(加签机器人时填写),webhook 前缀固定,无需填写完整 URL。消息类型:markdown。
|
||||
|
||||
**企业微信 `wecom`**
|
||||
|
||||
@@ -413,7 +432,7 @@ SMTP 使用全局 `config.yaml` 的 `smtp` 段;支持 587 STARTTLS / 465 TLS
|
||||
}
|
||||
```
|
||||
|
||||
`chat_id` 可为数字或字符串(含 `@username`)。消息以 MarkdownV2 发送:标题加粗,标题和正文均自动转义。
|
||||
`chat_id` 可为数字或字符串(含 `@username`)。消息以纯文本发送正文,不附带 `{source}: {event}` 标题,也不做 Markdown 转义。
|
||||
|
||||
#### `POST /api/v1/channels/safew/chats` — 列出已监控的 SafeW 群
|
||||
|
||||
@@ -465,12 +484,13 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page"
|
||||
前缀:`/api/v1/rules`
|
||||
**鉴权:** Admin Key
|
||||
|
||||
同一 Source 下 `event` 唯一。
|
||||
同一 Source 下允许重复 `event`;用规则条件和精确/通配优先级区分。条件通过的规则都会发送。
|
||||
|
||||
#### `POST /api/v1/rules` — 创建
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trade-open-alert",
|
||||
"source_name": "trading-system",
|
||||
"event": "trade.open",
|
||||
"template_name": "trade_open",
|
||||
@@ -485,8 +505,9 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page"
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 规则名称(代号),全局唯一 |
|
||||
| `source_name` | string | 是 | Source 名称 |
|
||||
| `event` | string | 是 | 事件名;text 模式请用 `default` |
|
||||
| `event` | string | 是 | 事件名;支持 `*` / `?` 通配(如 `trade.*`)。精确匹配优先于通配,更具体的通配优先于 `*`。text 模式请用 `default` |
|
||||
| `template_name` | string | 是 | Template 名称 |
|
||||
| `channels` | string[] | 否 | Channel 名称列表 |
|
||||
| `conditions` | object[] | 否 | 过滤条件,全部 AND;省略则不过滤 |
|
||||
@@ -499,7 +520,7 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page"
|
||||
| `eq` / `ne` | 等于 / 不等于(字符串比较) |
|
||||
| `gt` / `gte` / `lt` / `lte` | 数值比较 |
|
||||
| `exists` / `not_exists` | 字段是否存在 |
|
||||
| `contains` | 字符串包含 |
|
||||
| `contains` / `not_contains` | 字符串包含 / 不包含 |
|
||||
|
||||
条件结构:`{"field":"<字段>","op":"<操作符>","value":"<可选>"}`
|
||||
|
||||
@@ -584,13 +605,14 @@ curl -s -X POST http://localhost:8080/api/v1/channels \
|
||||
-d '{
|
||||
"name":"dingtalk-prod",
|
||||
"type":"dingtalk",
|
||||
"config":{"webhook_url":"https://oapi.dingtalk.com/robot/send?access_token=xxx","secret":"SEC..."}
|
||||
"config":{"access_token":"xxx","secret":"SEC..."}
|
||||
}'
|
||||
|
||||
# 4. 创建规则
|
||||
curl -s -X POST http://localhost:8080/api/v1/rules \
|
||||
-H "$ADMIN" -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name":"trade-open-alert",
|
||||
"source_name":"trading-system",
|
||||
"event":"trade.open",
|
||||
"template_name":"trade_open",
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ func main() {
|
||||
deduper := subscriber.NewCacheDeduper(redisCache, cfg.SubscriptionDedupTTL)
|
||||
for _, sub := range cfg.ActiveSubscriptions() {
|
||||
sub := sub
|
||||
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper)
|
||||
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper, redisCache)
|
||||
if err != nil {
|
||||
slog.Error("subscriber init", "name", sub.Name, "error", err)
|
||||
os.Exit(1)
|
||||
|
||||
+13
-1
@@ -34,7 +34,7 @@ logbull:
|
||||
api_key: "lb_60701971723797ed0374aa3896078fe5"
|
||||
log_level: "INFO"
|
||||
|
||||
# 多队列重复消息按 body SHA-256 去重;有 Redis 时跨进程共享
|
||||
# 多队列重复消息按来源/策略/币种/周期/方向/价格去重;有 Redis 时跨进程共享
|
||||
subscription_dedup_ttl: 1h
|
||||
|
||||
subscriptions:
|
||||
@@ -56,3 +56,15 @@ subscriptions:
|
||||
reduce: 100
|
||||
close: 100
|
||||
leverage: 100
|
||||
- name: crypto-strategy
|
||||
url: "${RABBITMQ_URL}"
|
||||
# 独立队列,不订 executor 队列,避免和 consumer.strategy 抢消息
|
||||
# strategy.signal.# 覆盖 strategy.signal 与 strategy.signal.origin
|
||||
queue: crypto.strategy.signal.notify.queue
|
||||
dead_letter_queue: crypto.strategy.signal.notify.dlq
|
||||
exchange: crypto.strategy.topic
|
||||
exchange_type: topic
|
||||
routing_key: strategy.signal.#
|
||||
max_retry: 3
|
||||
source: crypto-strategy
|
||||
formatter: crypto_strategy
|
||||
|
||||
+3
-2
@@ -6,8 +6,9 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- SMTP_PASSWORD=mssp.92QdSYS.3z0vklo982xl7qrx.tSlSpXs
|
||||
- DB_PASSWORD=7Qay8mksnwrCffGi
|
||||
SMTP_PASSWORD: "mssp.92QdSYS.3z0vklo982xl7qrx.tSlSpXs"
|
||||
DB_PASSWORD: "7Qay8mksnwrCffGi"
|
||||
RABBITMQ_URL: "amqps://gfzknmdk:BXoIOszWGpokmyP3FeQ64LqIldw8kf2v@gerbil.rmq.cloudamqp.com:5671/gfzknmdk"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- 1panel-network
|
||||
|
||||
+381
-1
@@ -221,7 +221,7 @@ curl -X POST 'http://localhost:8080/api/v1/channels' \
|
||||
"name": "dingtalk-prod",
|
||||
"type": "dingtalk",
|
||||
"config": {
|
||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"access_token": "xxx",
|
||||
"secret": "SEC..."
|
||||
},
|
||||
"status": 1
|
||||
@@ -341,6 +341,7 @@ curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "trade-open-alert",
|
||||
"source_name": "trading-system",
|
||||
"event": "trade.open",
|
||||
"template_name": "trade_open",
|
||||
@@ -374,6 +375,7 @@ curl -X PUT 'http://localhost:8080/api/v1/rules/1' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "trade-open-alert",
|
||||
"source_name": "trading-system",
|
||||
"event": "trade.open",
|
||||
"template_name": "trade_open",
|
||||
@@ -427,3 +429,381 @@ curl -X PATCH 'http://localhost:8080/api/v1/rules/1/channels/1/disable' \
|
||||
curl -X GET 'http://localhost:8080/api/v1/message-logs?source=trading-system&event=trade.open&status=success&page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI crypto signals(crypto-strategy)
|
||||
|
||||
Source:`crypto-strategy`。渠道:`safew_ai_crypto_signals`。条件:`strategyCode = ai-crypto-signals`。
|
||||
|
||||
事件:开仓(多/空)→ `trade.open`;止盈 → `trade.gain`;止损 → `trade.close`。空单开仓不再发 `trade.sell`。
|
||||
|
||||
开仓渲染示例:
|
||||
|
||||
```
|
||||
预警时间:2026-08-16 03:20:13
|
||||
预警币种:APE
|
||||
交易方向:做多
|
||||
建议杠杆:31x
|
||||
入场区域:0.1235
|
||||
风险控制(止损):0.1223
|
||||
止盈目标:
|
||||
TP1:0.1241
|
||||
TP2:0.1247
|
||||
TP3:0.1253
|
||||
TP4:0.1259
|
||||
TP5:0.1265
|
||||
推送时间:2026-08-16 03:20:13
|
||||
```
|
||||
|
||||
止盈渲染示例:
|
||||
|
||||
```
|
||||
止盈时间:2026-08-16 16:29:23
|
||||
预警币种:LTC
|
||||
执行操作:到达第一止盈 (TP1)
|
||||
平仓点位:44.63
|
||||
预警收益:+14.2793%
|
||||
预警周期:53分钟
|
||||
```
|
||||
|
||||
止损渲染示例:
|
||||
|
||||
```
|
||||
止损时间:2026-08-16 16:17:15
|
||||
预警币种:ETH
|
||||
执行操作:触发止损
|
||||
平仓点位:1887
|
||||
最终损益:-30.1557%
|
||||
```
|
||||
|
||||
### 创建开仓模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "ai_crypto_signals_open",
|
||||
"content": "预警时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n交易方向:{{case .side \"LONG\" \"做多\" \"SHORT\" \"做空\"}}\n{{line \"建议杠杆\" .leverageText}}入场区域:{{.entryRange}}\n{{line \"风险控制(止损)\" .stopLossPrice}}止盈目标:\n{{with .tp1}}TP1:{{.}}\n{{end}}{{with .tp2}}TP2:{{.}}\n{{end}}{{with .tp3}}TP3:{{.}}\n{{end}}{{with .tp4}}TP4:{{.}}\n{{end}}{{with .tp5}}TP5:{{.}}\n{{end}}推送时间:{{.pushedAt}}"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建止盈模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "ai_crypto_signals_gain",
|
||||
"content": "止盈时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n执行操作:{{.closeAction}}\n平仓点位:{{.price}}\n预警收益:{{.revenueDisplay}}\n{{line \"预警周期\" .holdPeriod}}"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建止损模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "ai_crypto_signals_close",
|
||||
"content": "止损时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n执行操作:触发止损\n平仓点位:{{.price}}\n最终损益:{{.revenueDisplay}}\n{{line \"预警周期\" .holdPeriod}}"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建开仓 / 止盈 / 止损规则
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "AI crypto signals 开仓",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "trade.open",
|
||||
"template_name": "ai_crypto_signals_open",
|
||||
"channels": ["safew_ai_crypto_signals"],
|
||||
"conditions": [
|
||||
{"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"}
|
||||
],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "AI crypto signals 止盈",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "trade.gain",
|
||||
"template_name": "ai_crypto_signals_gain",
|
||||
"channels": ["safew_ai_crypto_signals"],
|
||||
"conditions": [
|
||||
{"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"}
|
||||
],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "AI crypto signals 止损",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "trade.close",
|
||||
"template_name": "ai_crypto_signals_close",
|
||||
"channels": ["safew_ai_crypto_signals"],
|
||||
"conditions": [
|
||||
{"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"}
|
||||
],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AG 趋势 / 异动 / 波段跟踪(crypto-strategy)
|
||||
|
||||
Source:`crypto-strategy`。Convert 后事件为 `AGTS.*` / `AMA.*` / `BTS.*`(与 `trade.*` 分开,避免和 AI crypto signals 抢精确匹配)。
|
||||
|
||||
| 策略 | strategyCode | 样例 payload | Convert event |
|
||||
|------|--------------|--------------|---------------|
|
||||
| AG趋势 | `AGTS` | `isSale` 开仓,带 `gainPrices` / `openPrice2` / `lossPrice` | `AGTS.open` |
|
||||
| 异动 | `AMA` | 开仓,仅 `price` | `AMA.open` |
|
||||
| 波段跟踪 | `BTS` | `isClose=true`,仅 `price` | `BTS.close` |
|
||||
|
||||
有效期按样例写死:AG 6 天、异动 2-4 天、波段 17h。波段周期含 `2h` → `2小时`。
|
||||
|
||||
### 创建 AG 趋势模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "ag_trend",
|
||||
"content": "监控告警提醒\n\n操作策略:AG趋势{{case .symbol \"BTCUSDT\" \"BTC\" \"ETHUSDT\" \"ETH\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}-{{case .period \"1h\" \"1小时\" \"2h\" \"2小时\" \"4h\" \"4小时\" \"6h\" \"6小时\" \"15m\" \"15分钟\" \"5m\" \"5分钟\" \"30m\" \"30分钟\" \"1d\" \"1日\" .period}}周期{{case .side \"LONG\" \"做多\" \"SHORT\" \"做空\"}}\n\n提醒时间:{{.pushedAt}}\n\n{{with .takeProfitRange}}止盈目标:{{.}}\n\n{{else}}{{with .takeProfitPrice}}止盈目标:{{.}}\n\n{{end}}{{end}}{{with .entryRange}}介入区间:{{.}}\n\n{{else}}{{with .price}}介入区间:{{.}}\n\n{{end}}{{end}}{{with .stopLossPrice}}止损价位:{{.}}\n\n{{end}}有效期:6天"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建异动预警模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "anomaly_alert",
|
||||
"content": "监控告警提醒\n\n监控名称:异动预警\n\n监控时间:{{.pushedAt}}\n\n监控目标:{{case .symbol \"BTCUSDT\" \"BTC\" \"ETHUSDT\" \"ETH\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}异动预警(暴涨/跌)生效\n\n监控提醒:异动发生概率v1(v1<v2<v3)\n\n有效期:2-4天"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建波段跟踪模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "swing_track",
|
||||
"content": "监控告警提醒\n\n监控名称:波段跟踪触发{{case .symbol \"BTCUSDT\" \"BTC\" \"ETHUSDT\" \"ETH\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}-{{case .period \"1h\" \"1小时\" \"2h\" \"2小时\" \"4h\" \"4小时\" \"6h\" \"6小时\" \"15m\" \"15分钟\" \"5m\" \"5分钟\" \"30m\" \"30分钟\" \"1d\" \"1日\" .period}}周期{{case .side \"LONG\" \"做多\" \"SHORT\" \"做空\"}}\n\n监控时间:{{.pushedAt}}\n\n监控提醒:当前提醒价格{{with .takeProfitRange}}{{.}}{{else}}{{with .entryRange}}{{.}}{{else}}{{.price}}{{end}}{{end}}\n\n监控状态:等待量化信号平仓\n\n有效期: 17h"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建规则
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "AG趋势",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "AGTS.*",
|
||||
"template_name": "ag_trend",
|
||||
"channels": ["safew_AG趋势", "safew_AG趋势02", "safew_AG趋势03"],
|
||||
"conditions": [{"field": "strategyCode", "op": "eq", "value": "AGTS"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "异动预警",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "AMA.*",
|
||||
"template_name": "anomaly_alert",
|
||||
"channels": ["safew_异动策略", "safew_异动策略02", "safew_异动策略03"],
|
||||
"conditions": [{"field": "strategyCode", "op": "eq", "value": "AMA"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "波段跟踪",
|
||||
"source_name": "crypto-strategy",
|
||||
"event": "BTS.*",
|
||||
"template_name": "swing_track",
|
||||
"channels": ["safew_波段跟踪02", "safew_波段跟踪03"],
|
||||
"conditions": [{"field": "strategyCode", "op": "eq", "value": "BTS"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 跟单策略(trade-signal)
|
||||
|
||||
Source:`trade-signal`。渠道:`safew_B龙策略`。条件:`strategyCode = BLONG`。
|
||||
|
||||
这是后续跟单策略的共用文案模板(开/加/平/减仓一套)。新跟单策略复用模板 `跟单策略`,再加一条 `trade.*` 规则(换 `strategyCode`、渠道,并在模板 `case .strategyCode` 里补中文名)。兜底规则 `rule-11`(测试AI)需 `strategyCode ne` 已拆出去的跟单 code,避免双发。
|
||||
|
||||
事件:`OPEN` → `trade.open`,`ADD` → `trade.add`,`CLOSE` → `trade.close`,`REDUCE` → `trade.reduce`;规则用 `trade.*` 全覆盖。
|
||||
|
||||
开仓渲染示例:
|
||||
|
||||
```
|
||||
空单开仓
|
||||
交易品种: ETH
|
||||
开仓价格: 1898.76
|
||||
开仓数量: 2.00
|
||||
平均单价: 1898.76
|
||||
杠杆: 100x
|
||||
策略: B龙策略
|
||||
推送时间: 2026.08.13 13:15:42
|
||||
```
|
||||
|
||||
平仓 / 减仓不输出杠杆;加仓与开仓一样带杠杆。
|
||||
|
||||
### 创建跟单模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "跟单策略",
|
||||
"content": "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建 B龙规则
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "B龙策略",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "跟单策略",
|
||||
"channels": ["safew_B龙策略"],
|
||||
"conditions": [
|
||||
{"field": "strategyCode", "op": "eq", "value": "BLONG"}
|
||||
],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 巴菲特激进 / 稳健(trade-signal)
|
||||
|
||||
Source:`trade-signal`。渠道:`safew_巴菲特策略`、`safew_巴菲特策略2`、`safew_巴菲特策略3`(三个不同群;`巴菲特02/03` 与策略2/3 是同一 chat,不要重复绑)。
|
||||
|
||||
策略 code:激进 `PUTEJJ`、稳健 `PUTEWJ`,共用上游 `rawMessage`(激进版 / 稳健版文案已在原文里)。加仓也是「市价开多/开空」,走开仓规则。
|
||||
|
||||
兜底 `rule-11` 需 `strategyCode ne PUTEJJ` 且 `ne PUTEWJ`,避免再发到测试AI。
|
||||
|
||||
启动文案(`普达特量化机器人…启动`)不含「市价开 / 平仓 / 本周期」,三条规则都匹配不上,等于过滤。
|
||||
|
||||
开仓 `rawMessage` 示例:
|
||||
|
||||
```
|
||||
激进版AI 1.0
|
||||
市价开空
|
||||
交易品种: BTC
|
||||
开空数量: 1.00
|
||||
开空价格: 63578.00
|
||||
持仓数量: 1.00
|
||||
平均价格: 63578.00
|
||||
浮动盈亏: 0.00
|
||||
账户净值: 101286.40
|
||||
账户余额:101286.40
|
||||
Time: 2026.08.17 14:46:04
|
||||
```
|
||||
|
||||
### 创建模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特策略",
|
||||
"content": "{{if .rawMessage}}{{replace .rawMessage \"Time:\" \"推送时间:\"}}{{else}}{{.formatted}}{{end}}"
|
||||
}'
|
||||
```
|
||||
|
||||
线上若尚未部署 `replace`,先用 `{{if .rawMessage}}{{.rawMessage}}{{else}}{{.formatted}}{{end}}`。
|
||||
|
||||
### 创建开仓 / 平仓 / 提现规则
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特开仓",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "市价开"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特平仓",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "平仓"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特提现",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "本周期"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"dingtalk-prod\",\n \"type\": \"dingtalk\",\n \"config\": {\n \"webhook_url\": \"https://oapi.dingtalk.com/robot/send?access_token=xxx\",\n \"secret\": \"SEC...\"\n },\n \"status\": 1\n}"
|
||||
"raw": "{\n \"name\": \"dingtalk-prod\",\n \"type\": \"dingtalk\",\n \"config\": {\n \"access_token\": \"xxx\",\n \"secret\": \"SEC...\"\n },\n \"status\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/channels"
|
||||
}
|
||||
@@ -425,7 +425,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"conditions\": [\n {\"field\": \"symbol\", \"op\": \"exists\"},\n {\"field\": \"price\", \"op\": \"gt\", \"value\": \"0\"}\n ],\n \"enabled\": 1\n}"
|
||||
"raw": "{\n \"name\": \"trade-open-alert\",\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"conditions\": [\n {\"field\": \"symbol\", \"op\": \"exists\"},\n {\"field\": \"price\", \"op\": \"gt\", \"value\": \"0\"}\n ],\n \"enabled\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/rules"
|
||||
}
|
||||
@@ -467,7 +467,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"enabled\": 1\n}"
|
||||
"raw": "{\n \"name\": \"trade-open-alert\",\n \"source_name\": \"trading-system\",\n \"event\": \"trade.open\",\n \"template_name\": \"trade_open\",\n \"channels\": [\"email-ops\"],\n \"enabled\": 1\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/api/v1/rules/{{ruleId}}"
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ POST /api/v1/channels
|
||||
"name": "dingtalk-prod",
|
||||
"type": "dingtalk",
|
||||
"config": {
|
||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"access_token": "xxx",
|
||||
"secret": "SEC..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,25 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 钉钉自定义机器人 webhook 前缀固定,配置时只需提供 access_token
|
||||
const dingtalkWebhookPrefix = "https://oapi.dingtalk.com/robot/send?access_token="
|
||||
|
||||
type dingtalkConfig struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// WebhookURL 兼容旧配置;新配置优先使用 AccessToken
|
||||
WebhookURL string `json:"webhook_url,omitempty"`
|
||||
}
|
||||
|
||||
func (c *dingtalkConfig) webhookURL() string {
|
||||
if c.AccessToken != "" {
|
||||
return dingtalkWebhookPrefix + c.AccessToken
|
||||
}
|
||||
return c.WebhookURL
|
||||
}
|
||||
|
||||
type dingtalkMessage struct {
|
||||
@@ -41,23 +54,23 @@ func (s *DingTalkSender) Send(title, content string, config json.RawMessage) err
|
||||
}
|
||||
|
||||
if s.limiter != nil {
|
||||
if err := s.limiter.Acquire(context.Background(), DingTalkLimitKey(cfg.WebhookURL)); err != nil {
|
||||
if err := s.limiter.Acquire(context.Background(), DingTalkLimitKey(cfg.webhookURL())); err != nil {
|
||||
return fmt.Errorf("dingtalk rate limit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
reqURL := cfg.WebhookURL
|
||||
reqURL := cfg.webhookURL()
|
||||
if cfg.Secret != "" {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
sign := dingtalkSign(timestamp, cfg.Secret)
|
||||
reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", cfg.WebhookURL, timestamp, sign)
|
||||
reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", reqURL, timestamp, sign)
|
||||
}
|
||||
|
||||
msg := dingtalkMessage{
|
||||
MsgType: "markdown",
|
||||
Markdown: &dingtalkMD{
|
||||
Title: title,
|
||||
Text: content,
|
||||
Title: dingtalkTitle(title, content),
|
||||
Text: dingtalkMarkdownText(content),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -74,6 +87,35 @@ func (s *DingTalkSender) Send(title, content string, config json.RawMessage) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// dingtalkTitle is the markdown.Title shown on DingTalk PC as the card header
|
||||
// and in the session-list preview. Prefer the first content line (e.g. 空单开仓)
|
||||
// over the internal "source: event" title.
|
||||
func dingtalkTitle(fallback, content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
line, _, _ := strings.Cut(content, "\n")
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
return "通知"
|
||||
}
|
||||
|
||||
// dingtalkMarkdownText turns template newlines into DingTalk markdown paragraph
|
||||
// breaks. A single \n is collapsed to a space by DingTalk markdown, so each
|
||||
// logical line must be separated by \n\n.
|
||||
func dingtalkMarkdownText(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
for strings.Contains(content, "\n\n") {
|
||||
content = strings.ReplaceAll(content, "\n\n", "\n")
|
||||
}
|
||||
return strings.ReplaceAll(content, "\n", "\n\n")
|
||||
}
|
||||
|
||||
func dingtalkSign(timestamp int64, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
fmt.Fprintf(mac, "%d\n%s", timestamp, secret)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package adapter
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDingTalkTitleUsesFirstContentLine(t *testing.T) {
|
||||
got := dingtalkTitle("trade-signal: trade", "空单开仓\n交易品种: ETHUSDT")
|
||||
if got != "空单开仓" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkTitleFallsBackWhenContentEmpty(t *testing.T) {
|
||||
got := dingtalkTitle("trade-signal: trade", " \n")
|
||||
if got != "trade-signal: trade" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkMarkdownTextDoublesNewlines(t *testing.T) {
|
||||
in := "空单开仓\n交易品种: ETHUSDT\n开仓价格: 2542.33\n\n推送时间: 2026.08.27 16:30:12"
|
||||
got := dingtalkMarkdownText(in)
|
||||
want := "空单开仓\n\n交易品种: ETHUSDT\n\n开仓价格: 2542.33\n\n推送时间: 2026.08.27 16:30:12"
|
||||
if got != want {
|
||||
t.Fatalf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingtalkConfigWebhookURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg dingtalkConfig
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "access token only",
|
||||
cfg: dingtalkConfig{AccessToken: "tok-abc"},
|
||||
wantURL: "https://oapi.dingtalk.com/robot/send?access_token=tok-abc",
|
||||
},
|
||||
{
|
||||
name: "access token takes precedence over legacy url",
|
||||
cfg: dingtalkConfig{AccessToken: "tok-abc", WebhookURL: "https://legacy.example/webhook"},
|
||||
wantURL: "https://oapi.dingtalk.com/robot/send?access_token=tok-abc",
|
||||
},
|
||||
{
|
||||
name: "fallback to legacy url",
|
||||
cfg: dingtalkConfig{WebhookURL: "https://legacy.example/webhook"},
|
||||
wantURL: "https://legacy.example/webhook",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.cfg.webhookURL(); got != tt.wantURL {
|
||||
t.Fatalf("webhookURL() = %q, want %q", got, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type safewConfig struct {
|
||||
type safewMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
ParseMode string `json:"parse_mode,omitempty"`
|
||||
}
|
||||
|
||||
type safewAPIResponse struct {
|
||||
@@ -52,9 +52,8 @@ func (s *SafeWSender) Send(title, content string, config json.RawMessage) error
|
||||
}
|
||||
|
||||
payload := safewMessage{
|
||||
ChatID: chatID,
|
||||
Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content),
|
||||
ParseMode: "MarkdownV2",
|
||||
ChatID: chatID,
|
||||
Text: content,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -178,7 +177,9 @@ func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) ([
|
||||
if maxID > 0 {
|
||||
next = maxID + 1
|
||||
}
|
||||
slog.Info("safew getUpdates", "timeout", timeout, "updates", n, "groups", len(chats), "offset", next)
|
||||
if n > 0 {
|
||||
slog.Info("safew getUpdates", "timeout", timeout, "updates", n, "groups", len(chats), "offset", next)
|
||||
}
|
||||
return chats, next, nil
|
||||
}
|
||||
|
||||
@@ -233,19 +234,6 @@ func safewErrorDescription(body []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
func escapeMarkdownV2(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type SafewChat struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
@@ -11,32 +11,6 @@ import (
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
func TestEscapeMarkdownV2(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "underscore and dot", in: "hello_world.", want: `hello\_world\.`},
|
||||
{name: "empty", in: "", want: ""},
|
||||
{name: "no specials", in: "hello", want: "hello"},
|
||||
{name: "backslash first", in: `a\b`, want: `a\\b`},
|
||||
{
|
||||
name: "all specials",
|
||||
in: "_*[]()~`>#+-=|{}.!\\",
|
||||
want: "\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := escapeMarkdownV2(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("escapeMarkdownV2(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSenderSafew(t *testing.T) {
|
||||
s, err := NewSender("safew", &config.SMTPConfig{}, nil)
|
||||
if err != nil {
|
||||
@@ -72,10 +46,10 @@ func TestSafeWSenderSendSuccess(t *testing.T) {
|
||||
if gotBody["chat_id"] != "123456789" {
|
||||
t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"])
|
||||
}
|
||||
if gotBody["parse_mode"] != "MarkdownV2" {
|
||||
t.Fatalf("parse_mode = %#v, want MarkdownV2", gotBody["parse_mode"])
|
||||
if gotBody["parse_mode"] != nil && gotBody["parse_mode"] != "" {
|
||||
t.Fatalf("parse_mode = %#v, want omitted/plain", gotBody["parse_mode"])
|
||||
}
|
||||
wantText := "*" + escapeMarkdownV2("hello_world.") + "*\n" + escapeMarkdownV2("price=1.5")
|
||||
wantText := "price=1.5"
|
||||
if gotBody["text"] != wantText {
|
||||
t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText)
|
||||
}
|
||||
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type KVWrite struct {
|
||||
Key string
|
||||
Val []byte
|
||||
TTL time.Duration
|
||||
Delete bool
|
||||
}
|
||||
|
||||
func (c *Cache) GetRaw(ctx context.Context, key string) ([]byte, error) {
|
||||
if c == nil || c.rdb == nil {
|
||||
return nil, fmt.Errorf("redis unavailable")
|
||||
}
|
||||
b, err := c.rdb.Get(ctx, key).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (c *Cache) TxWrite(ctx context.Context, writes []KVWrite) error {
|
||||
if c == nil || c.rdb == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
if len(writes) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipe := c.rdb.TxPipeline()
|
||||
for _, w := range writes {
|
||||
if w.Delete {
|
||||
pipe.Del(ctx, w.Key)
|
||||
continue
|
||||
}
|
||||
pipe.Set(ctx, w.Key, w.Val, w.TTL)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
@@ -46,6 +46,11 @@ func evaluateOne(c model.Condition, data map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value)
|
||||
case "not_contains":
|
||||
if !fieldExists {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value)
|
||||
case "gt", "gte", "lt", "lte":
|
||||
if !fieldExists {
|
||||
return false
|
||||
|
||||
@@ -54,3 +54,16 @@ func TestEvaluate_Contains(t *testing.T) {
|
||||
t.Error("msg contains 'error', should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_NotContains(t *testing.T) {
|
||||
conds := []model.Condition{{Field: "rawMessage", Op: "not_contains", Value: "启动"}}
|
||||
if !Evaluate(conds, map[string]interface{}{"rawMessage": "激进版AI 1.0\n市价开空"}) {
|
||||
t.Fatal("open text should pass")
|
||||
}
|
||||
if Evaluate(conds, map[string]interface{}{"rawMessage": "普达特量化机器人激进版启动\n账户余额:100000.00"}) {
|
||||
t.Fatal("startup text should be filtered")
|
||||
}
|
||||
if !Evaluate(conds, map[string]interface{}{"strategyCode": "PUTEJJ"}) {
|
||||
t.Fatal("missing rawMessage should pass")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,17 @@ func (o StrategyOverride) QuantityMultiplierFor(action string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
func IsAMQPURL(u string) bool {
|
||||
u = strings.TrimSpace(u)
|
||||
return strings.HasPrefix(u, "amqp://") || strings.HasPrefix(u, "amqps://")
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeSubscriptions() error {
|
||||
for i := range c.Subscriptions {
|
||||
s := &c.Subscriptions[i]
|
||||
if s.URL == "" {
|
||||
s.URL = strings.TrimSpace(expandEnv(s.URL))
|
||||
if !IsAMQPURL(s.URL) {
|
||||
s.URL = ""
|
||||
continue
|
||||
}
|
||||
if s.Queue == "" {
|
||||
@@ -90,7 +97,7 @@ func (c *Config) NormalizeSubscriptions() error {
|
||||
if s.Formatter == "" {
|
||||
s.Formatter = "trade_signal"
|
||||
}
|
||||
if s.Formatter != "trade_signal" {
|
||||
if s.Formatter != "trade_signal" && s.Formatter != "crypto_strategy" {
|
||||
return fmt.Errorf("subscriptions[%d]: unknown formatter %q", i, s.Formatter)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +107,7 @@ func (c *Config) NormalizeSubscriptions() error {
|
||||
func (c *Config) ActiveSubscriptions() []SubscriptionConfig {
|
||||
out := make([]SubscriptionConfig, 0, len(c.Subscriptions))
|
||||
for _, s := range c.Subscriptions {
|
||||
if s.URL != "" {
|
||||
if IsAMQPURL(s.URL) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeSubscriptionDefaults(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
@@ -47,6 +51,91 @@ func TestNormalizeUnknownFormatter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpandsRabbitMQURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
yaml := []byte(`
|
||||
server:
|
||||
port: 8080
|
||||
admin_key: test
|
||||
subscriptions:
|
||||
- name: trade-signal
|
||||
url: "${RABBITMQ_URL}"
|
||||
queue: q
|
||||
source: trade-signal
|
||||
`)
|
||||
if err := os.WriteFile(path, yaml, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("RABBITMQ_URL", "amqps://user:pass@example.invalid:5671/vhost")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Subscriptions) != 1 {
|
||||
t.Fatalf("subs=%d", len(cfg.Subscriptions))
|
||||
}
|
||||
if cfg.Subscriptions[0].URL != "amqps://user:pass@example.invalid:5671/vhost" {
|
||||
t.Fatalf("url=%q", cfg.Subscriptions[0].URL)
|
||||
}
|
||||
if n := len(cfg.ActiveSubscriptions()); n != 1 {
|
||||
t.Fatalf("active=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSubscriptionsSkipsPlaceholderURL(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
URL: "${RABBITMQ_URL}", Queue: "q", Source: "s", Name: "trade-signal",
|
||||
}}}
|
||||
if n := len(cfg.ActiveSubscriptions()); n != 0 {
|
||||
t.Fatalf("placeholder should not be active, n=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesStrategyOverrides(t *testing.T) {
|
||||
cfg, err := Load(filepath.Join("..", "..", "config", "config.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sub *SubscriptionConfig
|
||||
for i := range cfg.Subscriptions {
|
||||
if cfg.Subscriptions[i].Name == "trade-signal" {
|
||||
sub = &cfg.Subscriptions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if sub == nil {
|
||||
t.Fatal("trade-signal subscription not found")
|
||||
}
|
||||
if len(sub.StrategyOverrides) == 0 {
|
||||
t.Fatalf("strategy_overrides not parsed: %+v", *sub)
|
||||
}
|
||||
// Viper lower-cases nested map keys during load, so the parsed key is "blong".
|
||||
o, ok := sub.StrategyOverrides["blong"]
|
||||
if !ok {
|
||||
t.Fatalf("blong override missing, got keys=%v", sub.StrategyOverrides)
|
||||
}
|
||||
got := o.QuantityMultiplierFor("OPEN")
|
||||
if got != 100 {
|
||||
t.Fatalf("open multiplier=%v want 100", got)
|
||||
}
|
||||
if o.Leverage == nil || *o.Leverage != 100 {
|
||||
t.Fatalf("leverage=%v want 100", o.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStrategyOverridesKeyInsensitive(t *testing.T) {
|
||||
// Confirms viper lower-cases keys; tradesignal.overrideFor matches case-insensitively.
|
||||
sub := SubscriptionConfig{StrategyOverrides: map[string]StrategyOverride{
|
||||
"blong": {Leverage: intPtr(100)},
|
||||
}}
|
||||
if _, ok := sub.StrategyOverrides["BLONG"]; ok {
|
||||
t.Fatalf("expected case-sensitive map; overrides=%v", sub.StrategyOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestQuantityMultiplierFor(t *testing.T) {
|
||||
o := StrategyOverride{QuantityMultipliers: QuantityMultipliers{Open: 100, Add: 0}}
|
||||
if o.QuantityMultiplierFor("OPEN") != 100 {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatPrice renders a price for display. Values >= 1 keep two decimals for
|
||||
// readability; smaller values use the shortest exact representation so tiny
|
||||
// prices like 0.00000059 are not collapsed to 0.00.
|
||||
func FormatPrice(v float64) string {
|
||||
if v == 0 {
|
||||
return "0.00"
|
||||
}
|
||||
if math.Abs(v) >= 1 {
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Decimal is a float64 that prints without collapsing sub-0.01 values to 0.00
|
||||
// and without scientific notation. Templates keep using {{printf "%.2f" .price}}
|
||||
// and {{.price}}; wrapping happens at render time so stored data stays numeric.
|
||||
type Decimal float64
|
||||
|
||||
func (d Decimal) Format(f fmt.State, verb rune) {
|
||||
v := float64(d)
|
||||
switch verb {
|
||||
case 'f', 'F':
|
||||
prec := 6
|
||||
if p, ok := f.Precision(); ok {
|
||||
prec = p
|
||||
}
|
||||
s := strconv.FormatFloat(v, byte(verb), prec, 64)
|
||||
if v != 0 && isCollapsedZero(s) {
|
||||
s = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
_, _ = io.WriteString(f, s)
|
||||
case 'v', 's':
|
||||
_, _ = io.WriteString(f, formatPlain(v))
|
||||
default:
|
||||
prec := -1
|
||||
if p, ok := f.Precision(); ok {
|
||||
prec = p
|
||||
}
|
||||
_, _ = io.WriteString(f, strconv.FormatFloat(v, byte(verb), prec, 64))
|
||||
}
|
||||
}
|
||||
|
||||
func formatPlain(v float64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func isCollapsedZero(s string) bool {
|
||||
t := strings.TrimPrefix(s, "-")
|
||||
t = strings.TrimPrefix(t, "+")
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
sawZero := false
|
||||
for _, r := range t {
|
||||
switch r {
|
||||
case '0':
|
||||
sawZero = true
|
||||
case '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return sawZero
|
||||
}
|
||||
|
||||
// WrapMap copies data and wraps float values so template printing keeps
|
||||
// precision. The original map is left unchanged for condition evaluation.
|
||||
func WrapMap(data map[string]interface{}) map[string]interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(data))
|
||||
for k, v := range data {
|
||||
out[k] = wrapValue(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wrapValue(v any) any {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case float64:
|
||||
return Decimal(t)
|
||||
case float32:
|
||||
return Decimal(t)
|
||||
case Decimal:
|
||||
return t
|
||||
case map[string]interface{}:
|
||||
return WrapMap(t)
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = wrapValue(x)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatPrice(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{0, "0.00"},
|
||||
{1898.76, "1898.76"},
|
||||
{1, "1.00"},
|
||||
{0.00000059, "0.00000059"},
|
||||
{-0.00000059, "-0.00000059"},
|
||||
{0.5, "0.5"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := FormatPrice(tc.in); got != tc.want {
|
||||
t.Errorf("FormatPrice(%v)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecimalPrintfKeepsTinyPrice(t *testing.T) {
|
||||
d := Decimal(0.00000059)
|
||||
if got := fmt.Sprintf("%.2f", d); got != "0.00000059" {
|
||||
t.Fatalf("%%.2f=%q", got)
|
||||
}
|
||||
if got := fmt.Sprintf("%v", d); got != "0.00000059" {
|
||||
t.Fatalf("%%v=%q", got)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", Decimal(1898.76)); got != "1898.76" {
|
||||
t.Fatalf("eth %%.2f=%q", got)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", Decimal(1000000000)); got != "1000000000.00" {
|
||||
t.Fatalf("qty %%.2f=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMapDoesNotMutate(t *testing.T) {
|
||||
in := map[string]interface{}{"price": 0.00000059}
|
||||
out := WrapMap(in)
|
||||
if _, ok := in["price"].(float64); !ok {
|
||||
t.Fatalf("input mutated: %T", in["price"])
|
||||
}
|
||||
if _, ok := out["price"].(Decimal); !ok {
|
||||
t.Fatalf("wrapped type=%T", out["price"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
// PickEventRule selects the best enabled rule for an event.
|
||||
// Exact event wins; otherwise glob patterns (* and ?) via path.Match.
|
||||
// Among globs, more literal characters win; ties go to the smaller ID.
|
||||
func PickEventRule(event string, rules []model.Rule) *model.Rule {
|
||||
picked := PickEventRules(event, rules)
|
||||
if len(picked) == 0 {
|
||||
return nil
|
||||
}
|
||||
best := &picked[0]
|
||||
for i := range picked[1:] {
|
||||
if picked[i+1].ID < best.ID {
|
||||
best = &picked[i+1]
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// PickEventRules returns every enabled rule at the winning specificity.
|
||||
// All exact event matches win as a group; otherwise all globs that share
|
||||
// the highest literal-character score.
|
||||
func PickEventRules(event string, rules []model.Rule) []model.Rule {
|
||||
var exact []model.Rule
|
||||
var globs []model.Rule
|
||||
bestScore := -1
|
||||
for i := range rules {
|
||||
r := rules[i]
|
||||
if r.Enabled == 0 {
|
||||
continue
|
||||
}
|
||||
if r.Event == event {
|
||||
exact = append(exact, r)
|
||||
continue
|
||||
}
|
||||
if !strings.ContainsAny(r.Event, "*?") {
|
||||
continue
|
||||
}
|
||||
ok, err := path.Match(r.Event, event)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
score := globSpecificity(r.Event)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
globs = []model.Rule{r}
|
||||
continue
|
||||
}
|
||||
if score == bestScore {
|
||||
globs = append(globs, r)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact
|
||||
}
|
||||
return globs
|
||||
}
|
||||
|
||||
func globSpecificity(pattern string) int {
|
||||
n := 0
|
||||
for _, r := range pattern {
|
||||
if r != '*' && r != '?' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
func TestPickEventRuleExact(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "trade.*", Enabled: 1},
|
||||
{ID: 2, Event: "trade.open", Enabled: 1},
|
||||
}
|
||||
got := PickEventRule("trade.open", rules)
|
||||
if got == nil || got.ID != 2 {
|
||||
t.Fatalf("want exact id=2, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRuleWildcard(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "trade.*", Enabled: 1},
|
||||
}
|
||||
got := PickEventRule("trade.close", rules)
|
||||
if got == nil || got.Event != "trade.*" {
|
||||
t.Fatalf("want trade.*, got %#v", got)
|
||||
}
|
||||
if PickEventRule("order.open", rules) != nil {
|
||||
t.Fatal("trade.* must not match order.open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRuleMoreSpecificWildcardWins(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "*", Enabled: 1},
|
||||
{ID: 2, Event: "trade.*", Enabled: 1},
|
||||
}
|
||||
got := PickEventRule("trade.open", rules)
|
||||
if got == nil || got.ID != 2 {
|
||||
t.Fatalf("want trade.* id=2, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRuleSkipsDisabled(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "trade.*", Enabled: 0},
|
||||
{ID: 2, Event: "*", Enabled: 1},
|
||||
}
|
||||
got := PickEventRule("trade.open", rules)
|
||||
if got == nil || got.ID != 2 {
|
||||
t.Fatalf("want * id=2, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRuleNoMatch(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "trade.open", Enabled: 1},
|
||||
}
|
||||
if PickEventRule("trade.close", rules) != nil {
|
||||
t.Fatal("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRulesAllExactMatches(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 12, Event: "trade.*", Enabled: 1},
|
||||
{ID: 17, Event: "trade.close", Enabled: 1},
|
||||
{ID: 18, Event: "trade.close", Enabled: 1},
|
||||
{ID: 19, Event: "trade.close", Enabled: 0},
|
||||
}
|
||||
got := PickEventRules("trade.close", rules)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 exact trade.close, got %#v", got)
|
||||
}
|
||||
ids := []int{got[0].ID, got[1].ID}
|
||||
if ids[0] != 17 || ids[1] != 18 {
|
||||
t.Fatalf("ids=%v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickEventRulesSameGlobBoth(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{ID: 1, Event: "trade.*", Enabled: 1},
|
||||
{ID: 2, Event: "trade.*", Enabled: 1},
|
||||
{ID: 3, Event: "*", Enabled: 1},
|
||||
}
|
||||
got := PickEventRules("trade.open", rules)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want both trade.*, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
@@ -12,49 +11,20 @@ import (
|
||||
|
||||
type Matcher struct {
|
||||
store *store.Store
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
func NewMatcher(s *store.Store, c *cache.Cache) *Matcher {
|
||||
return &Matcher{store: s, cache: c}
|
||||
func NewMatcher(s *store.Store, _ *cache.Cache) *Matcher {
|
||||
return &Matcher{store: s}
|
||||
}
|
||||
|
||||
func (m *Matcher) Match(ctx context.Context, sourceID int, event string) (*model.Rule, error) {
|
||||
// Try cache first
|
||||
if m.cache != nil {
|
||||
cr, err := m.cache.GetRule(ctx, sourceID, event)
|
||||
if err == nil {
|
||||
rule := &model.Rule{ID: cr.RuleID, TemplateID: cr.TemplateID, SourceID: sourceID, Event: event}
|
||||
if cr.Conditions != "" && cr.Conditions != "null" {
|
||||
raw := json.RawMessage(cr.Conditions)
|
||||
rule.Conditions = &raw
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to DB
|
||||
rule, err := m.store.GetRuleBySourceEvent(ctx, sourceID, event)
|
||||
func (m *Matcher) Match(ctx context.Context, sourceID int, event string) ([]model.Rule, error) {
|
||||
rules, err := m.store.ListEnabledRulesBySource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("match rule: %w", err)
|
||||
}
|
||||
|
||||
// Warm cache
|
||||
if m.cache != nil {
|
||||
tmpl, err := m.store.GetTemplate(ctx, rule.TemplateID)
|
||||
if err != nil {
|
||||
return rule, nil // rule found but template fetch failed — still return rule
|
||||
}
|
||||
cr := &cache.CachedRule{
|
||||
RuleID: rule.ID,
|
||||
TemplateID: rule.TemplateID,
|
||||
Content: tmpl.Content,
|
||||
}
|
||||
if rule.Conditions != nil {
|
||||
cr.Conditions = string(*rule.Conditions)
|
||||
}
|
||||
_ = m.cache.SetRule(ctx, sourceID, event, cr)
|
||||
picked := PickEventRules(event, rules)
|
||||
if len(picked) == 0 {
|
||||
return nil, fmt.Errorf("match rule: no rule for source %d event %s", sourceID, event)
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
return picked, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package engine
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"aiaa-notification-service/internal/display"
|
||||
)
|
||||
|
||||
type Renderer struct{}
|
||||
@@ -13,13 +17,85 @@ func NewRenderer() *Renderer {
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (string, error) {
|
||||
tmpl, err := template.New("notify").Option("missingkey=error").Parse(tmplContent)
|
||||
tmpl, err := template.New("notify").
|
||||
Option("missingkey=zero").
|
||||
Funcs(template.FuncMap{
|
||||
"line": templateLine,
|
||||
"case": templateCase,
|
||||
"replace": strings.ReplaceAll,
|
||||
}).
|
||||
Parse(tmplContent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
if err := tmpl.Execute(&buf, display.WrapMap(data)); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// templateLine renders "label:value\n" or empty if value is missing/zero.
|
||||
func templateLine(label string, v any) string {
|
||||
if isEmptyValue(v) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(label, "::") + ":" + fmt.Sprint(v) + "\n"
|
||||
}
|
||||
|
||||
func templateCase(value any, pairs ...any) string {
|
||||
got := stringify(value)
|
||||
n := len(pairs)
|
||||
def := got
|
||||
if n%2 == 1 {
|
||||
def = stringify(pairs[n-1])
|
||||
pairs = pairs[:n-1]
|
||||
}
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
if caseKeyMatch(got, stringify(pairs[i])) {
|
||||
return stringify(pairs[i+1])
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func caseKeyMatch(value, key string) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
k := strings.ToLower(strings.TrimSpace(key))
|
||||
k = strings.TrimPrefix(k, ".")
|
||||
vSeg := v
|
||||
if i := strings.LastIndex(v, "."); i >= 0 {
|
||||
vSeg = v[i+1:]
|
||||
}
|
||||
return k == v || k == strings.TrimPrefix(v, ".") || k == vSeg
|
||||
}
|
||||
|
||||
func stringify(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
func isEmptyValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return strings.TrimSpace(t) == ""
|
||||
case bool:
|
||||
return !t
|
||||
default:
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return rv.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return rv.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float() == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,221 @@ func TestRenderer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderer_Error(t *testing.T) {
|
||||
func TestRendererMissingKeyEmpty(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
_, err := r.Render("{{.nonexistent}}", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing field, got nil")
|
||||
_, err := r.Render("x{{.nonexistent}}y", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererLineOmitsEmpty(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
tmpl := `{{line "币种" .symbol}}{{line "平均价" .totalAvgPx}}{{line "价格" .price}}{{line "止损价" .stopLossPrice}}`
|
||||
out, err := r.Render(tmpl, map[string]interface{}{
|
||||
"symbol": "ICP",
|
||||
"price": 2.273,
|
||||
"totalAvgPx": "",
|
||||
"stopLossPrice": float64(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "币种:ICP") || !strings.Contains(out, "价格:2.273") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
if strings.Contains(out, "平均价") || strings.Contains(out, "止损价") {
|
||||
t.Fatalf("empty lines should be omitted, out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererLineLabelAlreadyHasColon(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{line "平均价:" .totalAvgPx}}`, map[string]interface{}{"totalAvgPx": 1029})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(out, ":") != 1 || !strings.Contains(out, "平均价:1029") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
}
|
||||
func TestRendererLineMissingKey(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{line "平均价" .totalAvgPx}}{{line "币种" .symbol}}`, map[string]interface{}{"symbol": "ICP"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "平均价") || !strings.Contains(out, "币种:ICP") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererCaseSwitch(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
tmpl := `{{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈"}}`
|
||||
out, err := r.Render(tmpl, map[string]interface{}{"action": "CLOSE"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "平仓" {
|
||||
t.Fatalf("action CLOSE: %q", out)
|
||||
}
|
||||
out, err = r.Render(tmpl, map[string]interface{}{"action": "open"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "开仓" {
|
||||
t.Fatalf("action open: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererCaseEventSuffix(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
tmpl := `{{case .event ".open" "开仓" ".close" "平仓"}}`
|
||||
out, err := r.Render(tmpl, map[string]interface{}{"event": "trade.close"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "平仓" {
|
||||
t.Fatalf("event trade.close: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererReplace(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{replace .rawMessage "Time:" "推送时间:"}}`, map[string]interface{}{
|
||||
"rawMessage": "激进版AI 1.0\n市价开空\nTime: 2026.08.17 14:46:04",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "推送时间: 2026.08.17 14:46:04") || strings.Contains(out, "Time:") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererCaseDefault(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{case .action "OPEN" "开仓" "未知"}}`, map[string]interface{}{"action": "HOLD"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "未知" {
|
||||
t.Fatalf("got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// copyTradeTmpl is the production 跟单策略 template. It formats prices with
|
||||
// printf "%.2f", which must not collapse meme-coin prices like PEPE to 0.00.
|
||||
const copyTradeTmpl = "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}"
|
||||
|
||||
func TestCopyTradeTemplate(t *testing.T) {
|
||||
tmpl := copyTradeTmpl
|
||||
r := NewRenderer()
|
||||
cases := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
want []string
|
||||
not []string
|
||||
}{
|
||||
{
|
||||
name: "open",
|
||||
data: map[string]interface{}{
|
||||
"side": "SHORT", "action": "OPEN", "symbol": "ETHUSDT",
|
||||
"price": 1898.76, "quantity": 2.0, "avgPrice": 1898.76,
|
||||
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.13 13:15:42",
|
||||
},
|
||||
want: []string{"空单开仓", "交易品种: ETH", "开仓价格: 1898.76", "开仓数量: 2.00", "平均单价: 1898.76", "杠杆: 100x", "策略: B龙策略", "推送时间: 2026.08.13 13:15:42"},
|
||||
},
|
||||
{
|
||||
name: "close",
|
||||
data: map[string]interface{}{
|
||||
"side": "SHORT", "action": "CLOSE", "symbol": "ETHUSDT",
|
||||
"price": 1883.35, "quantity": 2.0, "avgPrice": 1898.76,
|
||||
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.13 17:14:31",
|
||||
},
|
||||
want: []string{"空单平仓", "平仓价格: 1883.35", "平仓数量: 2.00", "策略: B龙策略", "推送时间: 2026.08.13 17:14:31"},
|
||||
not: []string{"杠杆:"},
|
||||
},
|
||||
{
|
||||
name: "reduce",
|
||||
data: map[string]interface{}{
|
||||
"side": "SHORT", "action": "REDUCE", "symbol": "ETHUSDT",
|
||||
"price": 1889.43, "quantity": 3.1, "avgPrice": 1899.03,
|
||||
"strategyCode": "BLONG", "pushedAt": "2026.08.12 22:05:02",
|
||||
},
|
||||
want: []string{"空单减仓", "减仓价格: 1889.43", "减仓数量: 3.10", "平均单价: 1899.03", "策略: B龙策略"},
|
||||
not: []string{"杠杆:"},
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
data: map[string]interface{}{
|
||||
"side": "SHORT", "action": "ADD", "symbol": "ETHUSDT",
|
||||
"price": 1933.05, "quantity": 3.8, "avgPrice": 1933.05,
|
||||
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.10 06:14:28",
|
||||
},
|
||||
want: []string{"空单加仓", "加仓价格: 1933.05", "加仓数量: 3.80", "杠杆: 100x", "策略: B龙策略", "推送时间: 2026.08.10 06:14:28"},
|
||||
},
|
||||
{
|
||||
name: "pepe-open-keeps-tiny-price",
|
||||
data: map[string]interface{}{
|
||||
"side": "SHORT", "action": "OPEN", "symbol": "PEPEUSDT",
|
||||
"price": 0.00000059, "quantity": 1000000000.0, "avgPrice": 0.00000059,
|
||||
"leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.22 10:04:29",
|
||||
},
|
||||
want: []string{
|
||||
"空单开仓", "交易品种: PEPEUSDT",
|
||||
"开仓价格: 0.00000059", "开仓数量: 1000000000.00",
|
||||
"平均单价: 0.00000059", "杠杆: 100x",
|
||||
"策略: B龙策略", "推送时间: 2026.08.22 10:04:29",
|
||||
},
|
||||
not: []string{"开仓价格: 0.00\n", "平均单价: 0.00\n"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := r.Render(tmpl, tc.data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("\n%s", out)
|
||||
for _, w := range tc.want {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in\n%s", w, out)
|
||||
}
|
||||
}
|
||||
for _, n := range tc.not {
|
||||
if strings.Contains(out, n) {
|
||||
t.Errorf("unexpected %q in\n%s", n, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererTinyPriceNotScientificOrRounded(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
data := map[string]interface{}{"price": 0.00000059, "stopLossPrice": 0.00000055}
|
||||
out, err := r.Render(`价格: {{.price}}
|
||||
{{line "止损价" .stopLossPrice}}`, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "e-") || strings.Contains(out, "E-") {
|
||||
t.Fatalf("tiny price lost precision:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "价格: 0.00000059") || !strings.Contains(out, "止损价:0.00000055") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererDoesNotMutateData(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
data := map[string]interface{}{"price": 0.00000059}
|
||||
if _, err := r.Render(`{{printf "%.2f" .price}}`, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := data["price"].(float64); !ok {
|
||||
t.Fatalf("render mutated caller data: %T", data["price"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -22,6 +23,7 @@ func NewRuleHandler(s *store.Store, c *cache.Cache) *RuleHandler {
|
||||
}
|
||||
|
||||
type createRuleReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
SourceName string `json:"source_name" binding:"required"`
|
||||
Event string `json:"event" binding:"required"`
|
||||
TemplateName string `json:"template_name" binding:"required"`
|
||||
@@ -65,6 +67,7 @@ func (h *RuleHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
rule := &model.Rule{
|
||||
Name: req.Name,
|
||||
SourceID: src.ID,
|
||||
Event: req.Event,
|
||||
TemplateID: tmpl.ID,
|
||||
@@ -76,7 +79,7 @@ func (h *RuleHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
rule.Channels = h.loadRuleChannels(c.Request.Context(), rule.ID)
|
||||
c.JSON(http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
@@ -93,6 +96,10 @@ func (h *RuleHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.fillRuleChannels(c, rules); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rules, "total": total, "page": page.Page})
|
||||
}
|
||||
|
||||
@@ -103,9 +110,78 @@ func (h *RuleHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"})
|
||||
return
|
||||
}
|
||||
rule.Channels = h.loadRuleChannels(c.Request.Context(), id)
|
||||
c.JSON(http.StatusOK, rule)
|
||||
}
|
||||
|
||||
// loadRuleChannels returns all channels bound to a rule, each with the per-rule
|
||||
// enabled switch, ordered by rule_channel id.
|
||||
func (h *RuleHandler) loadRuleChannels(ctx context.Context, ruleID int) []model.RuleChannelItem {
|
||||
byRule, err := h.store.ListRuleChannels(ctx, []int{ruleID})
|
||||
if err != nil || len(byRule) == 0 {
|
||||
return []model.RuleChannelItem{}
|
||||
}
|
||||
rcs := byRule[ruleID]
|
||||
if len(rcs) == 0 {
|
||||
return []model.RuleChannelItem{}
|
||||
}
|
||||
items := make([]model.RuleChannelItem, 0, len(rcs))
|
||||
for _, rc := range rcs {
|
||||
ch, err := h.store.GetChannel(ctx, rc.ChannelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, model.RuleChannelItem{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
Enabled: rc.Enabled,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// fillRuleChannels bulk-loads bound channels for rules and attaches them.
|
||||
func (h *RuleHandler) fillRuleChannels(ctx context.Context, rules []model.Rule) error {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int, 0, len(rules))
|
||||
for i := range rules {
|
||||
ids = append(ids, rules[i].ID)
|
||||
}
|
||||
byRule, err := h.store.ListRuleChannels(ctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(byRule) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range rules {
|
||||
r := &rules[i]
|
||||
rcs := byRule[r.ID]
|
||||
if len(rcs) == 0 {
|
||||
r.Channels = []model.RuleChannelItem{}
|
||||
continue
|
||||
}
|
||||
items := make([]model.RuleChannelItem, 0, len(rcs))
|
||||
for _, rc := range rcs {
|
||||
ch, err := h.store.GetChannel(ctx, rc.ChannelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, model.RuleChannelItem{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
Enabled: rc.Enabled,
|
||||
})
|
||||
}
|
||||
r.Channels = items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RuleHandler) Update(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var req createRuleReq
|
||||
@@ -138,6 +214,7 @@ func (h *RuleHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
rule := &model.Rule{
|
||||
Name: req.Name,
|
||||
SourceID: src.ID,
|
||||
Event: req.Event,
|
||||
TemplateID: tmpl.ID,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCreateRuleReqBindsName(t *testing.T) {
|
||||
var req createRuleReq
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"name":"高低分短线",
|
||||
"source_name":"crypto-strategy",
|
||||
"event":"HLSS.*",
|
||||
"template_name":"高低分短线"
|
||||
}`), &req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Name != "高低分短线" {
|
||||
t.Fatalf("name=%q", req.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRuleRequiresName(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewRuleHandler(nil, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/rules", h.Create)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/rules", bytes.NewReader([]byte(
|
||||
`{"source_name":"s","event":"trade.open","template_name":"t"}`,
|
||||
)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,16 @@ type Channel struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type RuleChannelItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
SourceID int `db:"source_id" json:"source_id"`
|
||||
Event string `db:"event" json:"event"`
|
||||
TemplateID int `db:"template_id" json:"template_id"`
|
||||
@@ -44,6 +52,9 @@ type Rule struct {
|
||||
Enabled int `db:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// Channels holds the channels bound to this rule. Populated by handlers when
|
||||
// reading rules; each item includes the per-rule enabled switch.
|
||||
Channels []RuleChannelItem `json:"channels"`
|
||||
}
|
||||
|
||||
type Condition struct {
|
||||
|
||||
+55
-20
@@ -8,10 +8,12 @@ import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/condition"
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/tz"
|
||||
)
|
||||
|
||||
var ErrUnprocessable = errors.New("unprocessable")
|
||||
@@ -30,7 +32,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
type RuleMatcher interface {
|
||||
Match(ctx context.Context, sourceID int, event string) (*model.Rule, error)
|
||||
Match(ctx context.Context, sourceID int, event string) ([]model.Rule, error)
|
||||
}
|
||||
|
||||
type TemplateStore interface {
|
||||
@@ -58,45 +60,85 @@ func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRo
|
||||
}
|
||||
|
||||
func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
|
||||
rule, err := s.matcher.Match(ctx, req.Source.ID, req.Event)
|
||||
if err != nil {
|
||||
rules, err := s.matcher.Match(ctx, req.Source.ID, req.Event)
|
||||
if err != nil || len(rules) == 0 {
|
||||
return Result{Matched: false}, nil
|
||||
}
|
||||
|
||||
if req.Data == nil {
|
||||
req.Data = map[string]interface{}{}
|
||||
}
|
||||
if _, ok := req.Data["event"]; !ok && req.Event != "" {
|
||||
req.Data["event"] = req.Event
|
||||
}
|
||||
if _, ok := req.Data["pushedAt"]; !ok {
|
||||
req.Data["pushedAt"] = tz.Format(time.Now(), "2006.01.02 15:04:05")
|
||||
}
|
||||
|
||||
var channels []string
|
||||
accepted := 0
|
||||
for i := range rules {
|
||||
chs, filtered, err := s.dispatch(ctx, req, &rules[i])
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if filtered {
|
||||
continue
|
||||
}
|
||||
accepted++
|
||||
channels = append(channels, chs...)
|
||||
}
|
||||
if accepted == 0 {
|
||||
return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil
|
||||
}
|
||||
|
||||
slog.Info("notification accepted",
|
||||
"source", req.Source.Name,
|
||||
"event", req.Event,
|
||||
"channels", channels,
|
||||
)
|
||||
return Result{Matched: true, Channels: channels}, nil
|
||||
}
|
||||
|
||||
func (s *Service) dispatch(ctx context.Context, req Request, rule *model.Rule) ([]string, bool, error) {
|
||||
if rule.Conditions != nil {
|
||||
var conds []model.Condition
|
||||
if err := json.Unmarshal(*rule.Conditions, &conds); err != nil {
|
||||
slog.Error("failed to unmarshal rule conditions", "rule_id", rule.ID, "error", err)
|
||||
return Result{}, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable)
|
||||
return nil, false, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable)
|
||||
}
|
||||
if !condition.Evaluate(conds, req.Data) {
|
||||
return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil
|
||||
return nil, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := s.templates.GetTemplate(ctx, rule.TemplateID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("template not found")
|
||||
return nil, false, fmt.Errorf("template not found")
|
||||
}
|
||||
|
||||
content, err := s.renderer.Render(tmpl.Content, req.Data)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
|
||||
return nil, false, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
|
||||
}
|
||||
|
||||
title := req.Source.Name + ": " + req.Event
|
||||
channels := s.router.Route(ctx, rule, title, content)
|
||||
|
||||
if s.logs != nil {
|
||||
ruleID := rule.ID
|
||||
srcName := req.Source.Name
|
||||
event := req.Event
|
||||
payloadJSON, _ := json.Marshal(req.Data)
|
||||
chs := append([]string(nil), channels...)
|
||||
go func() {
|
||||
payloadJSON, _ := json.Marshal(req.Data)
|
||||
logCtx := context.Background()
|
||||
for _, chName := range channels {
|
||||
for _, chName := range chs {
|
||||
ml := &model.MessageLog{
|
||||
RuleID: rule.ID,
|
||||
RuleID: ruleID,
|
||||
ChannelID: parseChannelID(chName),
|
||||
Source: req.Source.Name,
|
||||
Event: req.Event,
|
||||
Source: srcName,
|
||||
Event: event,
|
||||
Payload: payloadJSON,
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
@@ -107,14 +149,7 @@ func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("notification accepted",
|
||||
"source", req.Source.Name,
|
||||
"event", req.Event,
|
||||
"channels", channels,
|
||||
)
|
||||
|
||||
return Result{Matched: true, Channels: channels}, nil
|
||||
return channels, false, nil
|
||||
}
|
||||
|
||||
func parseChannelID(chName string) int {
|
||||
|
||||
@@ -4,19 +4,31 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
type fakeMatcher struct {
|
||||
rule *model.Rule
|
||||
err error
|
||||
rule *model.Rule
|
||||
rules []model.Rule
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMatcher) Match(context.Context, int, string) (*model.Rule, error) {
|
||||
return f.rule, f.err
|
||||
func (f *fakeMatcher) Match(context.Context, int, string) ([]model.Rule, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if len(f.rules) > 0 {
|
||||
return f.rules, nil
|
||||
}
|
||||
if f.rule != nil {
|
||||
return []model.Rule{*f.rule}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeTemplates struct {
|
||||
@@ -28,9 +40,19 @@ func (f *fakeTemplates) GetTemplate(context.Context, int) (*model.Template, erro
|
||||
return f.tmpl, f.err
|
||||
}
|
||||
|
||||
type fakeRouter struct{ channels []string }
|
||||
type fakeRouter struct {
|
||||
channels []string
|
||||
title string
|
||||
content string
|
||||
ruleIDs []int
|
||||
}
|
||||
|
||||
func (f *fakeRouter) Route(context.Context, *model.Rule, string, string) []string {
|
||||
func (f *fakeRouter) Route(_ context.Context, rule *model.Rule, title, content string) []string {
|
||||
f.title = title
|
||||
f.content = content
|
||||
if rule != nil {
|
||||
f.ruleIDs = append(f.ruleIDs, rule.ID)
|
||||
}
|
||||
return f.channels
|
||||
}
|
||||
|
||||
@@ -92,6 +114,58 @@ func TestProcessMatched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTemplateCanSwitchOnEvent(t *testing.T) {
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rule: &model.Rule{ID: 9, TemplateID: 1}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: `{{case .event ".open" "开仓" ".close" "平仓"}}`}},
|
||||
&fakeRouter{channels: []string{"safew:1"}},
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "crypto-strategy"},
|
||||
Event: "trade.close",
|
||||
Data: map[string]interface{}{"symbol": "XAU"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInjectsPushedAt(t *testing.T) {
|
||||
rt := &fakeRouter{channels: []string{"safew:1"}}
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rule: &model.Rule{ID: 9, TemplateID: 1}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: `{{line "推送时间" .pushedAt}}`}},
|
||||
rt,
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "crypto-strategy"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"symbol": "QNT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
if !strings.Contains(rt.content, "推送时间:") {
|
||||
t.Fatalf("content=%q", rt.content)
|
||||
}
|
||||
got := strings.TrimPrefix(rt.content, "推送时间:")
|
||||
got = strings.TrimSpace(got)
|
||||
cst := time.FixedZone("CST", 8*3600)
|
||||
parsed, err := time.ParseInLocation("2006.01.02 15:04:05", got, cst)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", got, err)
|
||||
}
|
||||
if d := time.Since(parsed); d < -2*time.Second || d > 2*time.Second {
|
||||
t.Fatalf("pushedAt %q is not UTC+8 now, drift=%s", got, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInvalidConditions(t *testing.T) {
|
||||
raw := json.RawMessage(`not-json`)
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||
@@ -116,3 +190,60 @@ func TestProcessTemplateMissing(t *testing.T) {
|
||||
t.Fatalf("want retryable error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMultipleRulesSameEvent(t *testing.T) {
|
||||
rt := &fakeRouter{channels: []string{"safew:1"}}
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rules: []model.Rule{
|
||||
{ID: 17, TemplateID: 1, Event: "trade.close"},
|
||||
{ID: 18, TemplateID: 1, Event: "trade.close"},
|
||||
}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: "ok"}},
|
||||
rt,
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 11, Name: "crypto-strategy"},
|
||||
Event: "trade.close",
|
||||
Data: map[string]interface{}{"symbol": "CRV"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || res.Filtered {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
if len(rt.ruleIDs) != 2 || rt.ruleIDs[0] != 17 || rt.ruleIDs[1] != 18 {
|
||||
t.Fatalf("routed=%v", rt.ruleIDs)
|
||||
}
|
||||
if len(res.Channels) != 2 {
|
||||
t.Fatalf("channels=%v", res.Channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSkipsFilteredSiblingRule(t *testing.T) {
|
||||
hlss := json.RawMessage(`[{"field":"strategyCode","op":"eq","value":"HLSS"}]`)
|
||||
ai := json.RawMessage(`[{"field":"strategyCode","op":"eq","value":"ai-crypto-signals"}]`)
|
||||
rt := &fakeRouter{channels: []string{"safew:1"}}
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rules: []model.Rule{
|
||||
{ID: 1, TemplateID: 1, Event: "trade.close", Conditions: &hlss},
|
||||
{ID: 2, TemplateID: 1, Event: "trade.close", Conditions: &ai},
|
||||
}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: "ok"}},
|
||||
rt,
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 11, Name: "crypto-strategy"},
|
||||
Event: "trade.close",
|
||||
Data: map[string]interface{}{"strategyCode": "ai-crypto-signals"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || res.Filtered {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
if len(rt.ruleIDs) != 1 || rt.ruleIDs[0] != 2 {
|
||||
t.Fatalf("routed=%v want only rule 2", rt.ruleIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ func (w *Watcher) pollOnce(ctx context.Context, token string, timeout int) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("safew poll", "timeout", timeout, "groups", len(chats), "offset", next)
|
||||
if len(chats) > 0 {
|
||||
slog.Info("safew poll", "timeout", timeout, "groups", len(chats), "offset", next)
|
||||
}
|
||||
if err := w.mergeAndLog(ctx, token, chats); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+49
-10
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int) error {
|
||||
@@ -16,12 +18,12 @@ func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `INSERT INTO notification_rule (source_id, event, template_id, conditions, enabled) VALUES (?, ?, ?, ?, ?)`
|
||||
query := `INSERT INTO notification_rule (name, source_id, event, template_id, conditions, enabled) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
condsJSON, err := marshalJSON(r.Conditions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, query, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled)
|
||||
result, err := tx.ExecContext(ctx, query, r.Name, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create rule: %w", err)
|
||||
}
|
||||
@@ -40,8 +42,8 @@ func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int)
|
||||
func (s *Store) GetRule(ctx context.Context, id int) (*model.Rule, error) {
|
||||
var r model.Rule
|
||||
var condsBytes []byte
|
||||
row := s.DB.QueryRowContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE id = ?`, id)
|
||||
if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
row := s.DB.QueryRowContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE id = ?`, id)
|
||||
if err := row.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("get rule %d: %w", id, err)
|
||||
}
|
||||
if len(condsBytes) > 0 && string(condsBytes) != "null" {
|
||||
@@ -54,9 +56,9 @@ func (s *Store) GetRule(ctx context.Context, id int) (*model.Rule, error) {
|
||||
func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event string) (*model.Rule, error) {
|
||||
var r model.Rule
|
||||
var condsBytes []byte
|
||||
query := `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND event = ? AND enabled = 1`
|
||||
query := `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND event = ? AND enabled = 1 ORDER BY id LIMIT 1`
|
||||
row := s.DB.QueryRowContext(ctx, query, sourceID, event)
|
||||
if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
if err := row.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("get rule by source+event: %w", err)
|
||||
}
|
||||
if len(condsBytes) > 0 && string(condsBytes) != "null" {
|
||||
@@ -66,6 +68,19 @@ func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event st
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEnabledRulesBySource(ctx context.Context, sourceID int) ([]model.Rule, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule WHERE source_id = ? AND enabled = 1`, sourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled rules by source: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
rules, err := scanRules(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRules(ctx context.Context, page PageFilter) ([]model.Rule, int, error) {
|
||||
var count int
|
||||
if err := s.DB.GetContext(ctx, &count, `SELECT COUNT(*) FROM notification_rule`); err != nil {
|
||||
@@ -73,7 +88,7 @@ func (s *Store) ListRules(ctx context.Context, page PageFilter) ([]model.Rule, i
|
||||
}
|
||||
|
||||
page.Normalize()
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule ORDER BY id LIMIT ? OFFSET ?`, page.PageSize, page.Offset())
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT id, name, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM notification_rule ORDER BY id LIMIT ? OFFSET ?`, page.PageSize, page.Offset())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list rules: %w", err)
|
||||
}
|
||||
@@ -96,8 +111,8 @@ func (s *Store) UpdateRule(ctx context.Context, id int, r *model.Rule, channelID
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal conditions: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE notification_rule SET source_id=?, event=?, template_id=?, conditions=?, enabled=? WHERE id=?`,
|
||||
r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled, id)
|
||||
_, err = tx.ExecContext(ctx, `UPDATE notification_rule SET name=?, source_id=?, event=?, template_id=?, conditions=?, enabled=? WHERE id=?`,
|
||||
r.Name, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update rule: %w", err)
|
||||
}
|
||||
@@ -145,6 +160,30 @@ func (s *Store) GetRuleChannels(ctx context.Context, ruleID int) ([]model.RuleCh
|
||||
return rcs, nil
|
||||
}
|
||||
|
||||
// ListRuleChannels maps each rule to its bound channels (regardless of enabled
|
||||
// state) and the per-rule enabled switch.
|
||||
func (s *Store) ListRuleChannels(ctx context.Context, ruleIDs []int) (map[int][]model.RuleChannel, error) {
|
||||
result := make(map[int][]model.RuleChannel, len(ruleIDs))
|
||||
if len(ruleIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
query, args, err := sqlx.In(`SELECT id, rule_id, channel_id, enabled FROM notification_rule_channel WHERE rule_id IN (?)`, ruleIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build rule channels query: %w", err)
|
||||
}
|
||||
query = s.DB.Rebind(query)
|
||||
|
||||
rcs := make([]model.RuleChannel, 0)
|
||||
if err := s.DB.SelectContext(ctx, &rcs, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("list rule channels: %w", err)
|
||||
}
|
||||
for _, rc := range rcs {
|
||||
result[rc.RuleID] = append(result[rc.RuleID], rc)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRuleChannelEnabled(ctx context.Context, ruleID, channelID int, enabled bool) error {
|
||||
v := 0
|
||||
if enabled {
|
||||
@@ -170,7 +209,7 @@ func scanRules(rows *sql.Rows) ([]model.Rule, error) {
|
||||
for rows.Next() {
|
||||
var r model.Rule
|
||||
var condsBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(condsBytes) > 0 && string(condsBytes) != "null" {
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package cryptostrategy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/display"
|
||||
"aiaa-notification-service/internal/tz"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
EventType string `json:"eventType"`
|
||||
CorrelationID string `json:"correlationId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Direction string `json:"direction"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
EventTime int64 `json:"eventTime"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
StrategyCode string `json:"strategyCode"`
|
||||
Period string `json:"period"`
|
||||
Currency string `json:"currency"`
|
||||
IsSale bool `json:"isSale"`
|
||||
IsClose bool `json:"isClose"`
|
||||
IsGain bool `json:"isGain"`
|
||||
GainTarget float64 `json:"gainTarget"`
|
||||
Price float64 `json:"price"`
|
||||
LossPrice float64 `json:"lossPrice"`
|
||||
GainPrices string `json:"gainPrices"`
|
||||
OpenPrice2 float64 `json:"openPrice2"`
|
||||
Remark string `json:"remark"`
|
||||
TotalGainTarget float64 `json:"totalGainTarget"`
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
|
||||
type remark struct {
|
||||
OrderID string `json:"orderId"`
|
||||
Revenue string `json:"revenue"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
|
||||
type Converter struct{}
|
||||
|
||||
func NewConverter() *Converter { return &Converter{} }
|
||||
|
||||
func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error) {
|
||||
return Convert(body)
|
||||
}
|
||||
|
||||
func Convert(body []byte) (string, map[string]interface{}, error) {
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid envelope: %w", err)
|
||||
}
|
||||
p, payloadJSON, err := parsePayload(body, env.Payload)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
action := inferAction(p)
|
||||
event := eventName(p.StrategyCode, action)
|
||||
text := format(env, p, action)
|
||||
side := strings.ToUpper(strings.TrimSpace(env.Direction))
|
||||
if side == "" {
|
||||
if p.IsSale {
|
||||
side = "SHORT"
|
||||
} else {
|
||||
side = "LONG"
|
||||
}
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"eventType": env.EventType,
|
||||
"correlationId": env.CorrelationID,
|
||||
"symbol": firstNonEmpty(env.Symbol, p.Currency),
|
||||
"direction": firstNonEmpty(env.Direction, side),
|
||||
"side": side,
|
||||
"action": action,
|
||||
"eventTime": env.EventTime,
|
||||
"strategyCode": p.StrategyCode,
|
||||
"period": p.Period,
|
||||
"currency": p.Currency,
|
||||
"isSale": p.IsSale,
|
||||
"isClose": p.IsClose,
|
||||
"isGain": p.IsGain,
|
||||
"gainTarget": p.GainTarget,
|
||||
"price": p.Price,
|
||||
"lossPrice": p.LossPrice,
|
||||
"gainPrices": p.GainPrices,
|
||||
"openPrice2": p.OpenPrice2,
|
||||
"leverage": p.Leverage,
|
||||
"formatted": text,
|
||||
"stopLossPrice": p.LossPrice,
|
||||
"takeProfitPrice": takeProfitPrice(p),
|
||||
"takeProfitRange": formatPriceRange(p.GainPrices),
|
||||
"entryRange": entryRange(p.Price, p.OpenPrice2),
|
||||
"totalAvgPx": "",
|
||||
}
|
||||
mergePayloadFields(data, payloadJSON)
|
||||
if env.EventTime > 0 {
|
||||
data["pushedAt"] = tz.Format(time.UnixMilli(env.EventTime), "2006-01-02 15:04:05")
|
||||
}
|
||||
if p.TotalGainTarget != 0 {
|
||||
data["totalGainTarget"] = p.TotalGainTarget
|
||||
}
|
||||
if p.Leverage > 0 {
|
||||
data["leverageText"] = fmt.Sprintf("%dx", p.Leverage)
|
||||
}
|
||||
for i, price := range splitPrices(p.GainPrices) {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
data[fmt.Sprintf("tp%d", i+1)] = compactPrice(price)
|
||||
}
|
||||
if p.IsGain {
|
||||
data["closeAction"] = formatTPAction(p.GainTarget)
|
||||
}
|
||||
if r := parseRemark(p.Remark); r.OrderID != "" || r.Revenue != "" || r.Period != "" {
|
||||
if r.OrderID != "" {
|
||||
data["orderId"] = r.OrderID
|
||||
}
|
||||
if r.Revenue != "" {
|
||||
data["revenue"] = r.Revenue
|
||||
data["revenueDisplay"] = formatRevenue(r.Revenue, p.IsGain)
|
||||
}
|
||||
if hp := formatHoldPeriod(r.Period); hp != "" {
|
||||
data["holdPeriod"] = hp
|
||||
}
|
||||
}
|
||||
return event, data, nil
|
||||
}
|
||||
|
||||
func parsePayload(body []byte, raw json.RawMessage) (payload, []byte, error) {
|
||||
var p payload
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return p, nil, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, body, nil
|
||||
}
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
asString = strings.TrimSpace(asString)
|
||||
if asString == "" {
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return p, nil, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, body, nil
|
||||
}
|
||||
raw = []byte(asString)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return p, nil, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, raw, nil
|
||||
}
|
||||
|
||||
func mergePayloadFields(data map[string]interface{}, payloadJSON []byte) {
|
||||
if len(bytes.TrimSpace(payloadJSON)) == 0 {
|
||||
return
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
if err := json.Unmarshal(payloadJSON, &extra); err != nil {
|
||||
return
|
||||
}
|
||||
for k, v := range extra {
|
||||
if _, ok := data[k]; ok {
|
||||
continue
|
||||
}
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func eventName(strategyCode, action string) string {
|
||||
code := strings.ToUpper(strings.TrimSpace(strategyCode))
|
||||
suffix := strings.ToLower(action)
|
||||
switch code {
|
||||
case "HLSS", "AMA", "BTS", "AGTS":
|
||||
return code + "." + suffix
|
||||
default:
|
||||
return "trade." + suffix
|
||||
}
|
||||
}
|
||||
|
||||
func inferAction(p payload) string {
|
||||
if strings.EqualFold(strings.TrimSpace(p.StrategyCode), "HLSS") {
|
||||
switch {
|
||||
case p.IsClose:
|
||||
return "CLOSE"
|
||||
case p.IsGain:
|
||||
return "GAIN"
|
||||
case p.IsSale:
|
||||
return "SELL"
|
||||
default:
|
||||
return "OPEN"
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case p.IsGain:
|
||||
return "GAIN"
|
||||
case p.IsClose:
|
||||
return "CLOSE"
|
||||
default:
|
||||
return "OPEN"
|
||||
}
|
||||
}
|
||||
|
||||
func takeProfitPrice(p payload) interface{} {
|
||||
if gp := strings.TrimSpace(p.GainPrices); gp != "" {
|
||||
return gp
|
||||
}
|
||||
if p.IsGain && p.Price > 0 {
|
||||
return p.Price
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRemark(raw string) remark {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return remark{}
|
||||
}
|
||||
var r remark
|
||||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||
return remark{}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func format(env envelope, p payload, action string) string {
|
||||
symbol := firstNonEmpty(env.Symbol, p.Currency)
|
||||
lines := []string{actionTitle(env.Direction, action)}
|
||||
if symbol != "" {
|
||||
lines = append(lines, fmt.Sprintf("交易品种: %s", symbol))
|
||||
}
|
||||
if p.Period != "" {
|
||||
lines = append(lines, fmt.Sprintf("周期: %s", p.Period))
|
||||
}
|
||||
switch action {
|
||||
case "CLOSE":
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("平仓价格: %s", display.FormatPrice(p.Price)))
|
||||
}
|
||||
case "GAIN":
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %s", display.FormatPrice(p.Price)))
|
||||
}
|
||||
case "SELL":
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("卖出价格: %s", display.FormatPrice(p.Price)))
|
||||
}
|
||||
default:
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %s", display.FormatPrice(p.Price)))
|
||||
}
|
||||
}
|
||||
if p.LossPrice > 0 {
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %s", display.FormatPrice(p.LossPrice)))
|
||||
}
|
||||
if er := entryRange(p.Price, p.OpenPrice2); er != "" && p.OpenPrice2 != 0 {
|
||||
lines = append(lines, fmt.Sprintf("介入区间: %s", er))
|
||||
}
|
||||
if gp := strings.TrimSpace(p.GainPrices); gp != "" && action != "GAIN" {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %s", strings.Join(splitPrices(gp), ", ")))
|
||||
}
|
||||
if p.GainTarget != 0 {
|
||||
lines = append(lines, fmt.Sprintf("止盈目标: %g", p.GainTarget))
|
||||
}
|
||||
if p.Leverage > 0 {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", p.Leverage))
|
||||
}
|
||||
if p.StrategyCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("策略: %s", p.StrategyCode))
|
||||
}
|
||||
if env.EventTime > 0 {
|
||||
t := time.UnixMilli(env.EventTime).In(tz.CST)
|
||||
lines = append(lines, fmt.Sprintf("Time: %s", t.Format("2006.01.02 15:04:05")))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func actionTitle(direction, action string) string {
|
||||
var pos string
|
||||
switch strings.ToUpper(direction) {
|
||||
case "LONG":
|
||||
pos = "多单"
|
||||
case "SHORT":
|
||||
pos = "空单"
|
||||
default:
|
||||
pos = direction
|
||||
}
|
||||
var act string
|
||||
switch action {
|
||||
case "OPEN":
|
||||
act = "开仓"
|
||||
case "CLOSE":
|
||||
act = "平仓"
|
||||
case "GAIN":
|
||||
act = "止盈"
|
||||
case "SELL":
|
||||
act = "卖出"
|
||||
default:
|
||||
act = action
|
||||
}
|
||||
return pos + act
|
||||
}
|
||||
|
||||
func splitPrices(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatPriceRange(s string) string {
|
||||
parts := splitPrices(s)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, compactPrice(p))
|
||||
}
|
||||
return strings.Join(out, "-")
|
||||
}
|
||||
|
||||
func entryRange(price, open2 float64) string {
|
||||
switch {
|
||||
case price != 0 && open2 != 0:
|
||||
return formatFloat(price) + "-" + formatFloat(open2)
|
||||
case price != 0:
|
||||
return formatFloat(price)
|
||||
case open2 != 0:
|
||||
return formatFloat(open2)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func compactPrice(s string) string {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return formatFloat(f)
|
||||
}
|
||||
|
||||
func formatFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func formatTPAction(gainTarget float64) string {
|
||||
n := int(gainTarget)
|
||||
names := []string{"", "第一", "第二", "第三", "第四", "第五"}
|
||||
if n >= 1 && n < len(names) {
|
||||
return fmt.Sprintf("到达%s止盈 (TP%d)", names[n], n)
|
||||
}
|
||||
return "到达止盈"
|
||||
}
|
||||
|
||||
func formatRevenue(raw string, isGain bool) string {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(raw), "%", "")
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(s, "+"), strings.HasPrefix(s, "-"):
|
||||
return s + "%"
|
||||
case isGain:
|
||||
return "+" + s + "%"
|
||||
default:
|
||||
return "-" + s + "%"
|
||||
}
|
||||
}
|
||||
|
||||
func formatHoldPeriod(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" || strings.EqualFold(s, "signal") {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(s, "小时") || strings.Contains(s, "分钟") {
|
||||
return s
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if n, ok := parseTrailingNumber(strings.TrimSpace(strings.TrimSuffix(lower, "min"))); ok {
|
||||
return formatMinutes(n)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseTrailingNumber(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func formatMinutes(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
h, m := n/60, n%60
|
||||
switch {
|
||||
case h > 0 && m > 0:
|
||||
return fmt.Sprintf("%d小时%d分钟", h, m)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d小时", h)
|
||||
default:
|
||||
return fmt.Sprintf("%d分钟", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package cryptostrategy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/engine"
|
||||
)
|
||||
|
||||
const sampleBody = `{
|
||||
"eventType": "SIGNAL_RECEIVED",
|
||||
"correlationId": "0_0_0",
|
||||
"symbol": "QNT",
|
||||
"direction": "LONG",
|
||||
"payload": "{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"QNT\",\"isSale\":false,\"isClose\":false,\"isGain\":false,\"gainTarget\":5,\"price\":58.23,\"lossPrice\":57.82,\"gainPrices\":\"58.435,58.64,58.845,59.05,59.255\",\"remark\":\"{\\\"orderId\\\":\\\"jeJY8l5YnYwfJbmj6zb4\\\"}\",\"totalGainTarget\":5,\"leverage\":43}",
|
||||
"eventTime": 1786802842899
|
||||
}`
|
||||
|
||||
func TestConvertParsesNestedPayload(t *testing.T) {
|
||||
event, data, err := Convert([]byte(sampleBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.open" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if data["symbol"] != "QNT" || data["strategyCode"] != "ai-crypto-signals" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if data["period"] != "1h" || data["direction"] != "LONG" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if data["orderId"] != "jeJY8l5YnYwfJbmj6zb4" {
|
||||
t.Fatalf("orderId=%v", data["orderId"])
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
for _, want := range []string{
|
||||
"多单开仓",
|
||||
"交易品种: QNT",
|
||||
"周期: 1h",
|
||||
"开仓价格: 58.23",
|
||||
"止损价格: 57.82",
|
||||
"止盈价格: 58.435, 58.64, 58.845, 59.05, 59.255",
|
||||
"杠杆: 43x",
|
||||
"策略: ai-crypto-signals",
|
||||
} {
|
||||
if !strings.Contains(formatted, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPushedAtIsUTC8(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"CRV","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"price\":0.2528}",
|
||||
"eventTime":1786899539730
|
||||
}`)
|
||||
_, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["pushedAt"] != "2026-08-17 00:58:59" {
|
||||
t.Fatalf("pushedAt=%v want UTC+8 2026-08-17 00:58:59", data["pushedAt"])
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "Time: 2026.08.17 00:58:59") {
|
||||
t.Fatalf("formatted=%s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCloseFlag(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"SHORT",
|
||||
"payload":"{\"isClose\":true,\"price\":64000,\"strategyCode\":\"x\",\"period\":\"4h\"}",
|
||||
"eventTime":1786802842899
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.close" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "空单平仓") {
|
||||
t.Fatalf("%s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFlatOneLayer(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED",
|
||||
"correlationId":"0_0_0",
|
||||
"symbol":"QNT",
|
||||
"direction":"LONG",
|
||||
"strategyCode":"ai-crypto-signals",
|
||||
"period":"1h",
|
||||
"currency":"QNT",
|
||||
"isSale":false,
|
||||
"isClose":false,
|
||||
"isGain":false,
|
||||
"gainTarget":5,
|
||||
"price":58.23,
|
||||
"lossPrice":57.82,
|
||||
"gainPrices":"58.435,58.64",
|
||||
"remark":"{\"orderId\":\"jeJY8l5YnYwfJbmj6zb4\"}",
|
||||
"leverage":43,
|
||||
"eventTime":1786802842899
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.open" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if data["strategyCode"] != "ai-crypto-signals" || data["price"] != 58.23 {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if data["orderId"] != "jeJY8l5YnYwfJbmj6zb4" {
|
||||
t.Fatalf("orderId=%v", data["orderId"])
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "多单开仓") || !strings.Contains(formatted, "开仓价格: 58.23") {
|
||||
t.Fatalf("%s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPayloadObject(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"ETH","direction":"SHORT",
|
||||
"payload":{"isClose":true,"price":3200,"strategyCode":"x","period":"1h"},
|
||||
"eventTime":1786802842899
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.close" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if data["price"] != float64(3200) {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRendersSharedSignalTemplate(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType": "SIGNAL_RECEIVED",
|
||||
"correlationId": "0_0_0",
|
||||
"symbol": "ICP",
|
||||
"direction": "LONG",
|
||||
"payload": "{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"ICP\",\"isSale\":false,\"isClose\":true,\"isGain\":true,\"gainTarget\":1,\"price\":2.273,\"remark\":\"{\\\"orderId\\\":\\\"uJs3zQI8IaRV16n8NERq\\\",\\\"revenue\\\":\\\"14.1088%\\\"}\",\"totalGainTarget\":5}",
|
||||
"eventTime": 1786808929754
|
||||
}`)
|
||||
_, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"totalAvgPx", "takeProfitPrice", "stopLossPrice", "side", "action", "symbol", "period", "price"} {
|
||||
if _, ok := data[key]; !ok {
|
||||
t.Fatalf("missing template key %q in %v", key, data)
|
||||
}
|
||||
}
|
||||
if data["orderId"] != "uJs3zQI8IaRV16n8NERq" || data["revenue"] != "14.1088%" {
|
||||
t.Fatalf("remark=%v", data)
|
||||
}
|
||||
tmpl := "### {{.symbol}} {{.action}}\n币种:{{.symbol}}\n周期:{{.period}}\n方向:{{.side}}\n价格:{{.price}}\n平均价:{{.totalAvgPx}}\n止盈价:{{.takeProfitPrice}}\n止损价:{{.stopLossPrice}}"
|
||||
out, err := engine.NewRenderer().Render(tmpl, data)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "ICP") || !strings.Contains(out, "GAIN") {
|
||||
t.Fatalf("out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertHLSSParsesGainPricesAndOpenPrice2(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType": "SIGNAL_RECEIVED",
|
||||
"correlationId": "0_0_0",
|
||||
"symbol": "BTC",
|
||||
"direction": "SHORT",
|
||||
"payload": "{\"strategyCode\":\"HLSS\",\"period\":\"30m\",\"currency\":\"BTC\",\"isSale\":true,\"isClose\":false,\"price\":63150.38,\"lossPrice\":63623.3,\"gainPrices\":\"62677.470000000000000,62456.770000000000000\",\"openPrice2\":63535.02,\"totalGainTarget\":2,\"leverage\":100}",
|
||||
"eventTime": 1786860019037
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "HLSS.sell" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if got := asFloat(t, data["openPrice2"]); got != 63535.02 {
|
||||
t.Fatalf("openPrice2=%v", data["openPrice2"])
|
||||
}
|
||||
if data["takeProfitRange"] != "62677.47-62456.77" {
|
||||
t.Fatalf("takeProfitRange=%v", data["takeProfitRange"])
|
||||
}
|
||||
if data["entryRange"] != "63150.38-63535.02" {
|
||||
t.Fatalf("entryRange=%v", data["entryRange"])
|
||||
}
|
||||
if _, ok := data["pushedAt"].(string); !ok {
|
||||
t.Fatalf("pushedAt=%v", data["pushedAt"])
|
||||
}
|
||||
tmpl := `监控告警提醒
|
||||
|
||||
操作策略:高低点分型{{case .symbol "BTCUSDT" "BTC" "ETHUSDT" "ETH" "SOLUSDT" "SOL" "BNBUSDT" "BNB" .symbol}}-{{case .period "1h" "1小时" "4h" "4小时" "15m" "15分钟" "5m" "5分钟" "30m" "30分钟" "1d" "1日" .period}}周期{{case .side "LONG" "做多" "SHORT" "做空"}}
|
||||
|
||||
提醒时间:{{.pushedAt}}
|
||||
|
||||
{{with .takeProfitRange}}止盈目标:{{.}}
|
||||
|
||||
{{else}}{{with .takeProfitPrice}}止盈目标:{{.}}
|
||||
|
||||
{{end}}{{end}}{{with .entryRange}}介入区间:{{.}}
|
||||
|
||||
{{else}}{{with .price}}介入区间:{{.}}
|
||||
|
||||
{{end}}{{end}}{{with .stopLossPrice}}止损价位:{{.}}
|
||||
{{end}}`
|
||||
out, err := engine.NewRenderer().Render(tmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"监控告警提醒",
|
||||
"操作策略:高低点分型BTC-30分钟周期做空",
|
||||
"止盈目标:62677.47-62456.77",
|
||||
"介入区间:63150.38-63535.02",
|
||||
"止损价位:63623.3",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat(t *testing.T, v any) float64 {
|
||||
t.Helper()
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case float32:
|
||||
return float64(n)
|
||||
default:
|
||||
t.Fatalf("want float, got %T %v", v, v)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertTinyPriceKeepsPrecision(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"PEPE","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"PEPE\",\"price\":0.00000059,\"lossPrice\":0.00000055,\"gainPrices\":\"0.00000061,0.00000064\"}",
|
||||
"eventTime":1786600000000
|
||||
}`)
|
||||
_, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
for _, want := range []string{
|
||||
"开仓价格: 0.00000059\n",
|
||||
"止损价格: 0.00000055\n",
|
||||
} {
|
||||
if !strings.Contains(formatted, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, formatted)
|
||||
}
|
||||
}
|
||||
if strings.Contains(formatted, "开仓价格: 0.00\n") {
|
||||
t.Fatalf("tiny price rounded to 0.00:\n%s", formatted)
|
||||
}
|
||||
out, err := engine.NewRenderer().Render("价格:{{.price}}\n止损:{{.stopLossPrice}}\n{{line \"止盈\" .takeProfitPrice}}", data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"价格:0.00000059", "止损:0.00000055"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in rendered\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "e-") || strings.Contains(out, "E-") {
|
||||
t.Fatalf("scientific notation in rendered\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := Convert([]byte(`{not json`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertSaleIsOpenNotSell(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"LTC\",\"isSale\":true,\"isClose\":false,\"isGain\":false,\"gainTarget\":5,\"price\":44.68,\"lossPrice\":44.91,\"gainPrices\":\"44.565,44.45,44.335,44.22,44.105\",\"leverage\":58}",
|
||||
"eventTime":1786894571102
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.open" {
|
||||
t.Fatalf("event=%q want trade.open", event)
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "空单开仓") {
|
||||
t.Fatalf("formatted=%s", formatted)
|
||||
}
|
||||
if strings.Contains(formatted, "空单卖出") {
|
||||
t.Fatalf("SHORT open must not say 卖出:\n%s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertTakeProfitIsGainEvent(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isSale\":true,\"isClose\":true,\"isGain\":true,\"gainTarget\":1,\"price\":44.63,\"remark\":\"{\\\"orderId\\\":\\\"x\\\",\\\"revenue\\\":\\\"14.2793%\\\",\\\"period\\\":\\\"53 min\\\"}\"}",
|
||||
"eventTime":1786897763403
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.gain" {
|
||||
t.Fatalf("event=%q want trade.gain", event)
|
||||
}
|
||||
if data["holdPeriod"] != "53分钟" {
|
||||
t.Fatalf("holdPeriod=%v", data["holdPeriod"])
|
||||
}
|
||||
if data["revenueDisplay"] != "+14.2793%" {
|
||||
t.Fatalf("revenueDisplay=%v", data["revenueDisplay"])
|
||||
}
|
||||
if data["closeAction"] != "到达第一止盈 (TP1)" {
|
||||
t.Fatalf("closeAction=%v", data["closeAction"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStopLossIsCloseEvent(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"ETH","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isSale\":true,\"isClose\":true,\"isGain\":false,\"price\":1887,\"remark\":\"{\\\"orderId\\\":\\\"x\\\",\\\"revenue\\\":\\\"30.1557%%\\\",\\\"period\\\":\\\"signal\\\"}\"}",
|
||||
"eventTime":1786897035113
|
||||
}`)
|
||||
event, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.close" {
|
||||
t.Fatalf("event=%q want trade.close", event)
|
||||
}
|
||||
if data["revenueDisplay"] != "-30.1557%" {
|
||||
t.Fatalf("revenueDisplay=%v", data["revenueDisplay"])
|
||||
}
|
||||
if _, ok := data["holdPeriod"]; ok {
|
||||
t.Fatalf("holdPeriod should be omitted for period=signal, got %v", data["holdPeriod"])
|
||||
}
|
||||
}
|
||||
|
||||
const aiCryptoOpenTmpl = `预警时间:{{.pushedAt}}
|
||||
预警币种:{{.symbol}}
|
||||
交易方向:{{case .side "LONG" "做多" "SHORT" "做空"}}
|
||||
{{line "建议杠杆" .leverageText}}入场区域:{{.entryRange}}
|
||||
{{line "风险控制(止损)" .stopLossPrice}}止盈目标:
|
||||
{{with .tp1}}TP1:{{.}}
|
||||
{{end}}{{with .tp2}}TP2:{{.}}
|
||||
{{end}}{{with .tp3}}TP3:{{.}}
|
||||
{{end}}{{with .tp4}}TP4:{{.}}
|
||||
{{end}}{{with .tp5}}TP5:{{.}}
|
||||
{{end}}推送时间:{{.pushedAt}}`
|
||||
|
||||
const aiCryptoGainTmpl = `止盈时间:{{.pushedAt}}
|
||||
预警币种:{{.symbol}}
|
||||
执行操作:{{.closeAction}}
|
||||
平仓点位:{{.price}}
|
||||
预警收益:{{.revenueDisplay}}
|
||||
{{line "预警周期" .holdPeriod}}`
|
||||
|
||||
const aiCryptoCloseTmpl = `止损时间:{{.pushedAt}}
|
||||
预警币种:{{.symbol}}
|
||||
执行操作:触发止损
|
||||
平仓点位:{{.price}}
|
||||
最终损益:{{.revenueDisplay}}
|
||||
{{line "预警周期" .holdPeriod}}`
|
||||
|
||||
func TestRenderAICryptoOpenTemplate(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"APE","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"APE\",\"isSale\":false,\"isClose\":false,\"isGain\":false,\"gainTarget\":5,\"price\":0.1235,\"lossPrice\":0.1223,\"gainPrices\":\"0.1241,0.1247,0.1253,0.1259,0.1265\",\"leverage\":31}",
|
||||
"eventTime":1786850413251
|
||||
}`)
|
||||
_, data, err := Convert(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := engine.NewRenderer().Render(aiCryptoOpenTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"预警币种:APE",
|
||||
"交易方向:做多",
|
||||
"建议杠杆:31x",
|
||||
"入场区域:0.1235",
|
||||
"风险控制(止损):0.1223",
|
||||
"TP1:0.1241",
|
||||
"TP2:0.1247",
|
||||
"TP3:0.1253",
|
||||
"TP4:0.1259",
|
||||
"TP5:0.1265",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAICryptoGainAndCloseTemplates(t *testing.T) {
|
||||
gainBody := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"isGain\":true,\"gainTarget\":2,\"price\":0.1598,\"remark\":\"{\\\"revenue\\\":\\\"22.67%\\\",\\\"period\\\":\\\"1小时38分钟\\\"}\"}",
|
||||
"eventTime":1786897763403
|
||||
}`)
|
||||
_, data, err := Convert(gainBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := engine.NewRenderer().Render(aiCryptoGainTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"预警币种:LTC",
|
||||
"执行操作:到达第二止盈 (TP2)",
|
||||
"平仓点位:0.1598",
|
||||
"预警收益:+22.67%",
|
||||
"预警周期:1小时38分钟",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
closeBody := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"ETH","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"isGain\":false,\"price\":57.95,\"remark\":\"{\\\"revenue\\\":\\\"30.28%\\\",\\\"period\\\":\\\"2小时55分钟\\\"}\"}",
|
||||
"eventTime":1786897035113
|
||||
}`)
|
||||
_, data, err = Convert(closeBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = engine.NewRenderer().Render(aiCryptoCloseTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"预警币种:ETH",
|
||||
"执行操作:触发止损",
|
||||
"平仓点位:57.95",
|
||||
"最终损益:-30.28%",
|
||||
"预警周期:2小时55分钟",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const agTrendTmpl = `监控告警提醒
|
||||
|
||||
操作策略:AG趋势{{case .symbol "BTCUSDT" "BTC" "ETHUSDT" "ETH" "SOLUSDT" "SOL" "BNBUSDT" "BNB" .symbol}}-{{case .period "1h" "1小时" "2h" "2小时" "4h" "4小时" "6h" "6小时" "15m" "15分钟" "5m" "5分钟" "30m" "30分钟" "1d" "1日" .period}}周期{{case .side "LONG" "做多" "SHORT" "做空"}}
|
||||
|
||||
提醒时间:{{.pushedAt}}
|
||||
|
||||
{{with .takeProfitRange}}止盈目标:{{.}}
|
||||
|
||||
{{else}}{{with .takeProfitPrice}}止盈目标:{{.}}
|
||||
|
||||
{{end}}{{end}}{{with .entryRange}}介入区间:{{.}}
|
||||
|
||||
{{else}}{{with .price}}介入区间:{{.}}
|
||||
|
||||
{{end}}{{end}}{{with .stopLossPrice}}止损价位:{{.}}
|
||||
|
||||
{{end}}有效期:6天`
|
||||
|
||||
const anomalyAlertTmpl = `监控告警提醒
|
||||
|
||||
监控名称:异动预警
|
||||
|
||||
监控时间:{{.pushedAt}}
|
||||
|
||||
监控目标:{{case .symbol "BTCUSDT" "BTC" "ETHUSDT" "ETH" "SOLUSDT" "SOL" "BNBUSDT" "BNB" .symbol}}异动预警(暴涨/跌)生效
|
||||
|
||||
监控提醒:异动发生概率v1(v1<v2<v3)
|
||||
|
||||
有效期:2-4天`
|
||||
|
||||
const swingTrackTmpl = `监控告警提醒
|
||||
|
||||
监控名称:波段跟踪触发{{case .symbol "BTCUSDT" "BTC" "ETHUSDT" "ETH" "SOLUSDT" "SOL" "BNBUSDT" "BNB" .symbol}}-{{case .period "1h" "1小时" "2h" "2小时" "4h" "4小时" "6h" "6小时" "15m" "15分钟" "5m" "5分钟" "30m" "30分钟" "1d" "1日" .period}}周期{{case .side "LONG" "做多" "SHORT" "做空"}}
|
||||
|
||||
监控时间:{{.pushedAt}}
|
||||
|
||||
监控提醒:当前提醒价格{{with .takeProfitRange}}{{.}}{{else}}{{with .entryRange}}{{.}}{{else}}{{.price}}{{end}}{{end}}
|
||||
|
||||
监控状态:等待量化信号平仓
|
||||
|
||||
有效期: 17h`
|
||||
|
||||
func TestRenderAGAnomalySwingTemplates(t *testing.T) {
|
||||
r := engine.NewRenderer()
|
||||
|
||||
ag, err := r.Render(agTrendTmpl, map[string]interface{}{
|
||||
"symbol": "BTC",
|
||||
"period": "6h",
|
||||
"side": "LONG",
|
||||
"pushedAt": "2026-08-07 16:00:20",
|
||||
"takeProfitRange": "66821.8-67536.1",
|
||||
"entryRange": "64938.6-64938.6",
|
||||
"stopLossPrice": "62406.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"操作策略:AG趋势BTC-6小时周期做多",
|
||||
"提醒时间:2026-08-07 16:00:20",
|
||||
"止盈目标:66821.8-67536.1",
|
||||
"介入区间:64938.6-64938.6",
|
||||
"止损价位:62406.0",
|
||||
"有效期:6天",
|
||||
} {
|
||||
if !strings.Contains(ag, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, ag)
|
||||
}
|
||||
}
|
||||
|
||||
yd, err := r.Render(anomalyAlertTmpl, map[string]interface{}{
|
||||
"symbol": "BTC",
|
||||
"pushedAt": "2026-07-29 00:00:50",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"监控名称:异动预警",
|
||||
"监控时间:2026-07-29 00:00:50",
|
||||
"监控目标:BTC异动预警(暴涨/跌)生效",
|
||||
"监控提醒:异动发生概率v1(v1<v2<v3)",
|
||||
"有效期:2-4天",
|
||||
} {
|
||||
if !strings.Contains(yd, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, yd)
|
||||
}
|
||||
}
|
||||
|
||||
bd, err := r.Render(swingTrackTmpl, map[string]interface{}{
|
||||
"symbol": "ETH",
|
||||
"period": "1h",
|
||||
"side": "LONG",
|
||||
"pushedAt": "2026-07-24 08:00:07",
|
||||
"takeProfitRange": "1878.4-1894.2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"监控名称:波段跟踪触发ETH-1小时周期做多",
|
||||
"监控时间:2026-07-24 08:00:07",
|
||||
"监控提醒:当前提醒价格1878.4-1894.2",
|
||||
"监控状态:等待量化信号平仓",
|
||||
"有效期: 17h",
|
||||
} {
|
||||
if !strings.Contains(bd, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, bd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAGTSAMAAndBTS(t *testing.T) {
|
||||
agBody := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"SHORT",
|
||||
"payload":"{\"strategyCode\":\"AGTS\",\"period\":\"6h\",\"currency\":\"BTC\",\"isSale\":true,\"isClose\":false,\"price\":63303,\"lossPrice\":65771.82,\"gainPrices\":\"61467.210000000000000,60770.880000000000000\",\"openPrice2\":63303.03,\"totalGainTarget\":2,\"leverage\":100}",
|
||||
"eventTime":1786780820000
|
||||
}`)
|
||||
event, data, err := Convert(agBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "AGTS.open" {
|
||||
t.Fatalf("AGTS event=%q", event)
|
||||
}
|
||||
if data["takeProfitRange"] != "61467.21-60770.88" {
|
||||
t.Fatalf("takeProfitRange=%v", data["takeProfitRange"])
|
||||
}
|
||||
if data["entryRange"] != "63303-63303.03" {
|
||||
t.Fatalf("entryRange=%v", data["entryRange"])
|
||||
}
|
||||
out, err := engine.NewRenderer().Render(agTrendTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"操作策略:AG趋势BTC-6小时周期做空",
|
||||
"止盈目标:61467.21-60770.88",
|
||||
"介入区间:63303-63303.03",
|
||||
"止损价位:65771.82",
|
||||
"有效期:6天",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
amaBody := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"AMA\",\"period\":\"4h\",\"currency\":\"BTC\",\"isSale\":false,\"isClose\":false,\"price\":63119.9,\"leverage\":100}",
|
||||
"eventTime":1785312050000
|
||||
}`)
|
||||
event, data, err = Convert(amaBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "AMA.open" || data["strategyCode"] != "AMA" {
|
||||
t.Fatalf("AMA event=%q data=%v", event, data)
|
||||
}
|
||||
out, err = engine.NewRenderer().Render(anomalyAlertTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "监控目标:BTC异动预警(暴涨/跌)生效") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
|
||||
btsBody := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"BTS\",\"period\":\"2h\",\"currency\":\"BTC\",\"isSale\":false,\"isClose\":true,\"isGain\":false,\"price\":63533.2}",
|
||||
"eventTime":1784865607000
|
||||
}`)
|
||||
event, data, err = Convert(btsBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "BTS.close" {
|
||||
t.Fatalf("BTS event=%q want BTS.close", event)
|
||||
}
|
||||
out, err = engine.NewRenderer().Render(swingTrackTmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"监控名称:波段跟踪触发BTC-2小时周期做多",
|
||||
"监控提醒:当前提醒价格63533.2",
|
||||
"监控状态:等待量化信号平仓",
|
||||
"有效期: 17h",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +24,53 @@ func MessageHash(body []byte) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func SignalHash(source string, data map[string]interface{}) string {
|
||||
sum := sha256.Sum256([]byte(signalKey(source, data)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func signalKey(source string, data map[string]interface{}) string {
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
strategy := fieldString(data["strategyCode"])
|
||||
symbol := firstNonEmptyField(fieldString(data["symbol"]), fieldString(data["currency"]))
|
||||
period := fieldString(data["period"])
|
||||
direction := strings.ToUpper(firstNonEmptyField(fieldString(data["direction"]), fieldString(data["side"])))
|
||||
action := strings.ToUpper(fieldString(data["action"]))
|
||||
price := fieldString(data["price"])
|
||||
return strings.Join([]string{strings.TrimSpace(source), strategy, symbol, period, direction, action, price}, "\x1f")
|
||||
}
|
||||
|
||||
func firstNonEmptyField(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func fieldString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(n)
|
||||
case float64:
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(n), 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(n)
|
||||
case int64:
|
||||
return strconv.FormatInt(n, 10)
|
||||
case json.Number:
|
||||
return n.String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryDeduper struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
|
||||
@@ -3,11 +3,13 @@ package subscriber
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/subscriber/cryptostrategy"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,73 @@ func TestMessageHashStable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalHashIgnoresUnrelatedFields(t *testing.T) {
|
||||
a := SignalHash("crypto-strategy", map[string]interface{}{
|
||||
"strategyCode": "ai-crypto-signals",
|
||||
"symbol": "CRV",
|
||||
"period": "1h",
|
||||
"direction": "LONG",
|
||||
"price": 0.2528,
|
||||
"eventTime": int64(1),
|
||||
})
|
||||
b := SignalHash("crypto-strategy", map[string]interface{}{
|
||||
"strategyCode": "ai-crypto-signals",
|
||||
"currency": "CRV",
|
||||
"period": "1h",
|
||||
"side": "long",
|
||||
"price": 0.2528,
|
||||
"eventTime": int64(2),
|
||||
})
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("same signal fields should hash equal, a=%q b=%q", a, b)
|
||||
}
|
||||
c := SignalHash("crypto-strategy", map[string]interface{}{
|
||||
"strategyCode": "ai-crypto-signals",
|
||||
"symbol": "CRV",
|
||||
"period": "1h",
|
||||
"direction": "LONG",
|
||||
"price": 0.26,
|
||||
})
|
||||
if a == c {
|
||||
t.Fatal("different price should hash differently")
|
||||
}
|
||||
otherSrc := SignalHash("trade-signal", map[string]interface{}{
|
||||
"strategyCode": "ai-crypto-signals",
|
||||
"symbol": "CRV",
|
||||
"period": "1h",
|
||||
"direction": "LONG",
|
||||
"price": 0.2528,
|
||||
})
|
||||
if a == otherSrc {
|
||||
t.Fatal("different sources should hash differently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalHashDistinguishesAction(t *testing.T) {
|
||||
base := map[string]interface{}{
|
||||
"strategyCode": "BLONG",
|
||||
"symbol": "SOLUSDT",
|
||||
"period": "30m",
|
||||
"side": "SHORT",
|
||||
"price": 101.32,
|
||||
}
|
||||
reduce := cloneFields(base)
|
||||
reduce["action"] = "REDUCE"
|
||||
closeMsg := cloneFields(base)
|
||||
closeMsg["action"] = "CLOSE"
|
||||
if SignalHash("trade-signal", reduce) == SignalHash("trade-signal", closeMsg) {
|
||||
t.Fatal("REDUCE and CLOSE at the same price should hash differently")
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFields(in map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(in)+1)
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMemoryDeduperClaimOnce(t *testing.T) {
|
||||
d := NewMemoryDeduper()
|
||||
ok, err := d.Claim(context.Background(), "abc")
|
||||
@@ -42,6 +111,125 @@ func TestMemoryDeduperClaimOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDedupByStrategySymbolPeriodDirectionPrice(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var n atomic.Int32
|
||||
process := func(context.Context, notify.Request) (notify.Result, error) {
|
||||
n.Add(1)
|
||||
return notify.Result{Matched: true}, nil
|
||||
}
|
||||
lookup := func(context.Context, string) (*model.Source, error) {
|
||||
return &model.Source{ID: 1, Name: "crypto-strategy", Status: 1}, nil
|
||||
}
|
||||
conv := cryptostrategy.NewConverter()
|
||||
in := func(eventTime int64) HandleInput {
|
||||
body := []byte(fmt.Sprintf(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"CRV","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"CRV\",\"isClose\":true,\"isGain\":true,\"price\":0.2528}",
|
||||
"eventTime":%d
|
||||
}`, eventTime))
|
||||
return HandleInput{Body: body, SourceName: "crypto-strategy", MaxRetry: 3, Deduper: dedup}
|
||||
}
|
||||
|
||||
if d := HandleMessage(context.Background(), in(1786899538978), conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("first=%v", d)
|
||||
}
|
||||
if d := HandleMessage(context.Background(), in(1786899539730), conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("dup=%v", d)
|
||||
}
|
||||
if n.Load() != 1 {
|
||||
t.Fatalf("same strategy/symbol/period/direction/price should process once, got %d", n.Load())
|
||||
}
|
||||
|
||||
bodyDiffPrice := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"CRV","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"CRV\",\"isClose\":true,\"isGain\":true,\"price\":0.26}",
|
||||
"eventTime":1786899539731
|
||||
}`)
|
||||
if d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: bodyDiffPrice, SourceName: "crypto-strategy", MaxRetry: 3, Deduper: dedup,
|
||||
}, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("diff price=%v", d)
|
||||
}
|
||||
if n.Load() != 2 {
|
||||
t.Fatalf("different price should process again, got %d", n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDedupKeepsReduceThenClose(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var events []string
|
||||
process := func(_ context.Context, req notify.Request) (notify.Result, error) {
|
||||
events = append(events, req.Event)
|
||||
return notify.Result{Matched: true}, nil
|
||||
}
|
||||
lookup := func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil }
|
||||
conv := tradesignal.NewConverter(nil)
|
||||
reduce := []byte(`{
|
||||
"signalId":"local-0f6f4b00e143f8ee:update:1787629790828:0.12",
|
||||
"sourcePosId":"local-0f6f4b00e143f8ee","sourcePosIds":["3340443882114850823"],
|
||||
"strategyCode":"BLONG","symbol":"SOLUSDT","side":"SHORT","action":"REDUCE",
|
||||
"quantity":0.11,"price":101.32,"leverage":20,"period":"30m",
|
||||
"eventTime":"2026-08-25T03:49:51.155Z","addCount":0,"totalPos":0.12,
|
||||
"totalAvgPx":102.64,"posMarginRatio":0.478261,"oldQuantity":0.23,"deltaQuantity":-0.11
|
||||
}`)
|
||||
closeBody := []byte(`{
|
||||
"signalId":"local-0f6f4b00e143f8ee:close:1787629790936:0",
|
||||
"sourcePosId":"local-0f6f4b00e143f8ee","sourcePosIds":["3340443882114850823"],
|
||||
"strategyCode":"BLONG","symbol":"SOLUSDT","side":"SHORT","action":"CLOSE",
|
||||
"quantity":0.12,"price":101.32,"leverage":1,"period":"30m",
|
||||
"eventTime":"2026-08-25T03:49:51.389Z","addCount":0,"totalPos":0,
|
||||
"totalAvgPx":0,"posMarginRatio":1,"oldQuantity":0.12
|
||||
}`)
|
||||
if d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: reduce, SourceName: "trade-signal", MaxRetry: 3, Deduper: dedup,
|
||||
}, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("reduce=%v", d)
|
||||
}
|
||||
if d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: closeBody, SourceName: "trade-signal", MaxRetry: 3, Deduper: dedup,
|
||||
}, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("close=%v", d)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("reduce then close should both notify, got %v", events)
|
||||
}
|
||||
if events[0] != "trade.reduce" || events[1] != "trade.close" {
|
||||
t.Fatalf("events=%v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDedupKeepsDifferentSources(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var n atomic.Int32
|
||||
process := func(context.Context, notify.Request) (notify.Result, error) {
|
||||
n.Add(1)
|
||||
return notify.Result{Matched: true}, nil
|
||||
}
|
||||
lookup := func(_ context.Context, name string) (*model.Source, error) {
|
||||
return &model.Source{ID: 1, Name: name, Status: 1}, nil
|
||||
}
|
||||
conv := cryptostrategy.NewConverter()
|
||||
body := []byte(`{
|
||||
"eventType":"SIGNAL_RECEIVED","symbol":"CRV","direction":"LONG",
|
||||
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"CRV\",\"isClose\":true,\"isGain\":true,\"price\":0.2528}",
|
||||
"eventTime":1786899538978
|
||||
}`)
|
||||
if d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: body, SourceName: "crypto-strategy", MaxRetry: 3, Deduper: dedup,
|
||||
}, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("first=%v", d)
|
||||
}
|
||||
if d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: body, SourceName: "trade-signal", MaxRetry: 3, Deduper: dedup,
|
||||
}, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("other source=%v", d)
|
||||
}
|
||||
if n.Load() != 2 {
|
||||
t.Fatalf("different sources should both process, got %d", n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDuplicateAckSkipsProcess(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var n atomic.Int32
|
||||
|
||||
@@ -28,6 +28,8 @@ type ProcessFunc func(ctx context.Context, req notify.Request) (notify.Result, e
|
||||
type HandleInput struct {
|
||||
Body []byte
|
||||
Headers map[string]any
|
||||
Name string
|
||||
Queue string
|
||||
SourceName string
|
||||
MaxRetry int
|
||||
Deduper Deduper
|
||||
@@ -65,14 +67,36 @@ func RetryCount(headers map[string]any) int {
|
||||
}
|
||||
}
|
||||
|
||||
func HandleMessage(ctx context.Context, in HandleInput, conv *tradesignal.Converter, lookup SourceLookup, process ProcessFunc) Disposition {
|
||||
type MessageConverter interface {
|
||||
Convert(body []byte) (event string, data map[string]interface{}, err error)
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, lookup SourceLookup, process ProcessFunc) Disposition {
|
||||
raw := string(in.Body)
|
||||
event, data, err := conv.Convert(in.Body)
|
||||
if err != nil {
|
||||
hash := MessageHash(in.Body)
|
||||
if errors.Is(err, tradesignal.ErrPositionStore) {
|
||||
slog.Warn("position store failed, retry", "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DecideRetry(RetryCount(in.Headers), in.MaxRetry)
|
||||
}
|
||||
slog.Warn("invalid signal, ack", "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
owned := false
|
||||
hash := ""
|
||||
hash := SignalHash(in.SourceName, data)
|
||||
if in.Deduper != nil {
|
||||
hash = MessageHash(in.Body)
|
||||
ok, err := in.Deduper.Claim(ctx, hash)
|
||||
if err != nil {
|
||||
slog.Warn("dedup claim failed, processing anyway", "hash", hash, "error", err)
|
||||
slog.Warn("dedup claim failed, processing anyway", "hash", hash, "error", errText(err))
|
||||
} else if !ok {
|
||||
slog.Info("duplicate message, ack", "hash", hash)
|
||||
return DispositionAck
|
||||
@@ -81,36 +105,34 @@ func HandleMessage(ctx context.Context, in HandleInput, conv *tradesignal.Conver
|
||||
}
|
||||
}
|
||||
|
||||
event, data, err := conv.Convert(in.Body)
|
||||
if err != nil {
|
||||
slog.Warn("invalid signal, ack", "error", err)
|
||||
return DispositionAck
|
||||
}
|
||||
slog.Info("mq message", "name", in.Name, "queue", in.Queue, "hash", hash, "raw", raw)
|
||||
|
||||
src, err := lookup(ctx, in.SourceName)
|
||||
if err != nil || src == nil || src.Status != 1 {
|
||||
slog.Warn("source unavailable, ack", "source", in.SourceName, "error", err)
|
||||
slog.Warn("source unavailable, ack", "source", in.SourceName, "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
res, err := process(ctx, notify.Request{Source: src, Event: event, Data: data})
|
||||
if err == nil {
|
||||
if !res.Matched {
|
||||
slog.Info("no matching rule", "source", src.Name, "event", event)
|
||||
slog.Info("no matching rule", "source", src.Name, "event", event, "hash", hash, "raw", raw)
|
||||
} else if res.Filtered {
|
||||
slog.Info("rule filtered", "source", src.Name, "event", event, "reason", res.Reason)
|
||||
slog.Info("rule filtered", "source", src.Name, "event", event, "reason", res.Reason, "hash", hash, "raw", raw)
|
||||
} else {
|
||||
slog.Info("mq message accepted", "source", src.Name, "event", event, "channels", res.Channels, "hash", hash, "raw", raw)
|
||||
}
|
||||
return DispositionAck
|
||||
}
|
||||
if errors.Is(err, notify.ErrUnprocessable) {
|
||||
slog.Warn("unprocessable notify, ack", "source", src.Name, "event", event, "error", err)
|
||||
slog.Warn("unprocessable notify, ack", "source", src.Name, "event", event, "hash", hash, "raw", raw, "error", errText(err))
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
disp := DecideRetry(RetryCount(in.Headers), in.MaxRetry)
|
||||
if owned && in.Deduper != nil {
|
||||
if relErr := in.Deduper.Release(ctx, hash); relErr != nil {
|
||||
slog.Warn("dedup release failed", "hash", hash, "error", relErr)
|
||||
slog.Warn("dedup release failed", "hash", hash, "error", errText(relErr))
|
||||
}
|
||||
}
|
||||
return disp
|
||||
|
||||
@@ -108,6 +108,28 @@ func TestHandleProcessErrorRetryThenDLQ(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePositionStoreErrorRetries(t *testing.T) {
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: []byte(`{"action":"OPEN"}`), SourceName: "s", MaxRetry: 3,
|
||||
}, stubConverter{err: tradesignal.ErrPositionStore},
|
||||
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
|
||||
func(context.Context, notify.Request) (notify.Result, error) {
|
||||
t.Fatal("process should not run")
|
||||
return notify.Result{}, nil
|
||||
})
|
||||
if d != DispositionRetry {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
type stubConverter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubConverter) Convert([]byte) (string, map[string]interface{}, error) {
|
||||
return "", nil, s.err
|
||||
}
|
||||
|
||||
func TestHandleSuccessAckPassesEventAndFormatted(t *testing.T) {
|
||||
var got notify.Request
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
"aiaa-notification-service/internal/config"
|
||||
"aiaa-notification-service/internal/subscriber/cryptostrategy"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
@@ -14,19 +17,25 @@ import (
|
||||
|
||||
type Subscriber struct {
|
||||
cfg config.SubscriptionConfig
|
||||
conv *tradesignal.Converter
|
||||
conv MessageConverter
|
||||
lookup SourceLookup
|
||||
process ProcessFunc
|
||||
deduper Deduper
|
||||
}
|
||||
|
||||
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper) (*Subscriber, error) {
|
||||
if cfg.Formatter != "trade_signal" {
|
||||
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper, redisCache *cache.Cache) (*Subscriber, error) {
|
||||
var conv MessageConverter
|
||||
switch cfg.Formatter {
|
||||
case "trade_signal":
|
||||
conv = tradesignal.NewConverterWithCache(cfg.StrategyOverrides, redisCache)
|
||||
case "crypto_strategy":
|
||||
conv = cryptostrategy.NewConverter()
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown formatter %q", cfg.Formatter)
|
||||
}
|
||||
return &Subscriber{
|
||||
cfg: cfg,
|
||||
conv: tradesignal.NewConverter(cfg.StrategyOverrides),
|
||||
conv: conv,
|
||||
lookup: lookup,
|
||||
process: process,
|
||||
deduper: deduper,
|
||||
@@ -42,7 +51,10 @@ func (s *Subscriber) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if err := s.consumeOnce(ctx); err != nil {
|
||||
slog.Error("subscriber error, reconnecting", "name", s.cfg.Name, "error", err)
|
||||
slog.Error("subscriber error, reconnecting",
|
||||
"name", s.cfg.Name,
|
||||
"host", brokerHost(s.cfg.URL),
|
||||
"error", err.Error())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -111,16 +123,26 @@ func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
|
||||
|
||||
if s.cfg.DeadLetterQueue != "" {
|
||||
if _, err := ch.QueueDeclare(s.cfg.DeadLetterQueue, true, false, false, false, nil); err != nil {
|
||||
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err)
|
||||
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Subscriber) handleDelivery(ch *amqp.Channel, d amqp.Delivery) {
|
||||
slog.Info("raw message received",
|
||||
"name", s.cfg.Name,
|
||||
"queue", s.cfg.Queue,
|
||||
"source", s.cfg.Source,
|
||||
"delivery_tag", d.DeliveryTag,
|
||||
"body", string(d.Body),
|
||||
)
|
||||
|
||||
disp := HandleMessage(context.Background(), HandleInput{
|
||||
Body: d.Body,
|
||||
Headers: map[string]any(d.Headers),
|
||||
Name: s.cfg.Name,
|
||||
Queue: s.cfg.Queue,
|
||||
SourceName: s.cfg.Source,
|
||||
MaxRetry: s.cfg.MaxRetry,
|
||||
Deduper: s.deduper,
|
||||
@@ -145,7 +167,7 @@ func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string)
|
||||
headers := copyAMQPHeaders(d.Headers)
|
||||
headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1
|
||||
if err := publishToQueue(ch, queue, d.Body, headers); err != nil {
|
||||
slog.Error("requeue failed", "queue", queue, "error", err)
|
||||
slog.Error("requeue failed", "queue", queue, "error", err.Error())
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
@@ -157,6 +179,17 @@ func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string)
|
||||
slog.Info("message requeued", "name", s.cfg.Name, "retry", headers[retryHeader], "max", s.cfg.MaxRetry)
|
||||
}
|
||||
|
||||
func brokerHost(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Port() != "" {
|
||||
return u.Hostname() + ":" + u.Port()
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func copyAMQPHeaders(headers amqp.Table) amqp.Table {
|
||||
out := amqp.Table{}
|
||||
for k, v := range headers {
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
var ErrInvalidSignal = errors.New("invalid signal")
|
||||
var ErrPositionStore = errors.New("position store")
|
||||
|
||||
type Converter struct {
|
||||
overrides map[string]config.StrategyOverride
|
||||
@@ -17,9 +19,13 @@ type Converter struct {
|
||||
}
|
||||
|
||||
func NewConverter(overrides map[string]config.StrategyOverride) *Converter {
|
||||
return NewConverterWithCache(overrides, nil)
|
||||
}
|
||||
|
||||
func NewConverterWithCache(overrides map[string]config.StrategyOverride, c *cache.Cache) *Converter {
|
||||
return &Converter{
|
||||
overrides: overrides,
|
||||
positions: NewTracker(),
|
||||
positions: NewTrackerWithStore(newRedisStore(c)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,17 +35,27 @@ func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error)
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrInvalidSignal, err)
|
||||
}
|
||||
if strings.TrimSpace(sig.Action) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
if strings.TrimSpace(sig.RawMessage) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
}
|
||||
data, err := toData(body, &sig)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "trade.message", data, nil
|
||||
}
|
||||
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
|
||||
snap := c.positions.Apply(out)
|
||||
snap, err := c.positions.Apply(out)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrPositionStore, err)
|
||||
}
|
||||
var opts FormatOptions
|
||||
if snap.HasAvg {
|
||||
avg := snap.AvgPrice
|
||||
opts.AvgPrice = &avg
|
||||
}
|
||||
text := Format(out, opts)
|
||||
data, err := toData(out)
|
||||
data, err := toData(body, out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -54,21 +70,31 @@ func (c *Converter) overrideFor(code string) *config.StrategyOverride {
|
||||
if c == nil || len(c.overrides) == 0 || code == "" {
|
||||
return nil
|
||||
}
|
||||
override, ok := c.overrides[code]
|
||||
if !ok {
|
||||
return nil
|
||||
// viper lower-cases nested map keys, so match case-insensitively.
|
||||
if override, ok := c.overrides[code]; ok {
|
||||
return &override
|
||||
}
|
||||
return &override
|
||||
if override, ok := c.overrides[strings.ToLower(code)]; ok {
|
||||
return &override
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toData(sig *Signal) (map[string]interface{}, error) {
|
||||
func toData(body []byte, sig *Signal) (map[string]interface{}, error) {
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
overlay := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &overlay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range overlay {
|
||||
data[k] = v
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
"aiaa-notification-service/internal/engine"
|
||||
)
|
||||
|
||||
func TestConvertOpen(t *testing.T) {
|
||||
@@ -36,6 +37,38 @@ func TestConvertOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverrideForLowercaseKey verifies overrides still match when viper
|
||||
// lower-cases the strategy_overrides map keys during config load.
|
||||
func TestOverrideForLowercaseKey(t *testing.T) {
|
||||
lev := 100
|
||||
c := NewConverter(map[string]config.StrategyOverride{
|
||||
"blong": {QuantityMultipliers: config.QuantityMultipliers{Add: 100}, Leverage: &lev},
|
||||
})
|
||||
o := c.overrideFor("BLONG")
|
||||
if o == nil {
|
||||
t.Fatal("overrideFor(BLONG)=nil, lowercase config key should match")
|
||||
}
|
||||
if got := o.QuantityMultiplierFor("ADD"); got != 100 {
|
||||
t.Fatalf("add multiplier=%v want 100", got)
|
||||
}
|
||||
if o.Leverage == nil || *o.Leverage != 100 {
|
||||
t.Fatalf("leverage=%v want 100", o.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKeepsExtraJSONFields(t *testing.T) {
|
||||
_, data, err := NewConverter(nil).Convert([]byte(`{
|
||||
"action":"OPEN","symbol":"BTCUSDT","price":63014.61,
|
||||
"totalAvgPx":63014.61,"investmentAmount":100095.24
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data["totalAvgPx"] != 63014.61 {
|
||||
t.Fatalf("totalAvgPx=%v", data["totalAvgPx"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
@@ -49,3 +82,59 @@ func TestConvertMissingAction(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRawMessageWithoutAction(t *testing.T) {
|
||||
event, data, err := NewConverter(nil).Convert([]byte(`{
|
||||
"strategyCode":"PUTEJJ",
|
||||
"rawMessage":"4.7.15~2026.8.15 本周期 10万本金 期末109640,盈利9.6%"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.message" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if data["rawMessage"] != "4.7.15~2026.8.15 本周期 10万本金 期末109640,盈利9.6%" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertThenRenderCopyTradeTinyPEPE(t *testing.T) {
|
||||
lev := 100
|
||||
c := NewConverter(map[string]config.StrategyOverride{
|
||||
"BLONG": {QuantityMultipliers: config.QuantityMultipliers{Open: 100}, Leverage: &lev},
|
||||
})
|
||||
_, data, err := c.Convert([]byte(`{
|
||||
"signalId":"s1","strategyCode":"BLONG","symbol":"PEPEUSDT",
|
||||
"side":"SHORT","action":"OPEN","quantity":10000000,"price":0.00000059,
|
||||
"leverage":10,"eventTime":"2026-08-22T02:04:29Z"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, ok := data["price"].(float64); !ok || p != 0.00000059 {
|
||||
t.Fatalf("convert price=%v (%T), want float64 0.00000059", data["price"], data["price"])
|
||||
}
|
||||
data["pushedAt"] = "2026.08.22 10:04:29"
|
||||
|
||||
const tmpl = "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}"
|
||||
out, err := engine.NewRenderer().Render(tmpl, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"空单开仓",
|
||||
"交易品种: PEPEUSDT",
|
||||
"开仓价格: 0.00000059",
|
||||
"开仓数量: 1000000000.00",
|
||||
"平均单价: 0.00000059",
|
||||
"杠杆: 100x",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "开仓价格: 0.00\n") || strings.Contains(out, "平均单价: 0.00\n") {
|
||||
t.Errorf("tiny price collapsed to 0.00:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package tradesignal
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/display"
|
||||
"aiaa-notification-service/internal/tz"
|
||||
)
|
||||
|
||||
type FormatOptions struct {
|
||||
@@ -28,7 +30,7 @@ func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
action := strings.ToUpper(signal.Action)
|
||||
switch action {
|
||||
case "OPEN":
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %s", display.FormatPrice(signal.Price)))
|
||||
if line := sizeLine("OPEN", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
@@ -37,23 +39,23 @@ func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
if signal.TakeProfitPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %.2f", *signal.TakeProfitPrice))
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %s", display.FormatPrice(*signal.TakeProfitPrice)))
|
||||
}
|
||||
if signal.StopLossPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %.2f", *signal.StopLossPrice))
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %s", display.FormatPrice(*signal.StopLossPrice)))
|
||||
}
|
||||
case "CLOSE":
|
||||
lines = append(lines, fmt.Sprintf("平仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, fmt.Sprintf("平仓价格: %s", display.FormatPrice(signal.Price)))
|
||||
lines = append(lines, closeSizeLine(signal.Quantity, signal.PosMarginRatio))
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("平仓盈亏: %.2f", *signal.PnL))
|
||||
lines = append(lines, fmt.Sprintf("平仓盈亏: %s", display.FormatPrice(*signal.PnL)))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%s", display.FormatPrice(*signal.AccountBalance)))
|
||||
}
|
||||
case "ADD":
|
||||
lines = append(lines, fmt.Sprintf("加仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, fmt.Sprintf("加仓价格: %s", display.FormatPrice(signal.Price)))
|
||||
if line := sizeLine("ADD", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
@@ -62,19 +64,19 @@ func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
case "REDUCE":
|
||||
lines = append(lines, fmt.Sprintf("减仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, fmt.Sprintf("减仓价格: %s", display.FormatPrice(signal.Price)))
|
||||
if line := sizeLine("REDUCE", signal.Quantity, signal.PosMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("减仓盈亏: %.2f", *signal.PnL))
|
||||
lines = append(lines, fmt.Sprintf("减仓盈亏: %s", display.FormatPrice(*signal.PnL)))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%s", display.FormatPrice(*signal.AccountBalance)))
|
||||
}
|
||||
default:
|
||||
lines = append(lines, fmt.Sprintf("价格: %.2f", signal.Price))
|
||||
lines = append(lines, fmt.Sprintf("价格: %s", display.FormatPrice(signal.Price)))
|
||||
if line := sizeLine("", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
@@ -85,7 +87,7 @@ func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
lines = append(lines, fmt.Sprintf("策略: %s", signal.StrategyCode))
|
||||
}
|
||||
|
||||
eventTime := signal.ParsedEventTime().In(time.Local)
|
||||
eventTime := signal.ParsedEventTime().In(tz.CST)
|
||||
lines = append(lines, fmt.Sprintf("Time: %s", eventTime.Format("2006.01.02 15:04:05")))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
@@ -126,7 +128,7 @@ func appendAvgPrice(lines []string, avgPrice *float64) []string {
|
||||
if avgPrice == nil || *avgPrice <= 0 {
|
||||
return lines
|
||||
}
|
||||
return append(lines, fmt.Sprintf("平均单价: %.2f", *avgPrice))
|
||||
return append(lines, fmt.Sprintf("平均单价: %s", display.FormatPrice(*avgPrice)))
|
||||
}
|
||||
|
||||
func closeSizeLine(quantity, posMarginRatio *float64) string {
|
||||
|
||||
@@ -58,3 +58,24 @@ func TestFormatWithAvgPrice(t *testing.T) {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTinyPriceKeepsPrecision(t *testing.T) {
|
||||
tp, sl := 0.00000061, 0.00000058
|
||||
out := Format(&Signal{
|
||||
Symbol: "PEPEUSDT", Side: "LONG", Action: "OPEN",
|
||||
Price: 0.00000059, TakeProfitPrice: &tp, StopLossPrice: &sl,
|
||||
EventTime: "2026-06-23T01:30:00Z",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"开仓价格: 0.00000059\n",
|
||||
"止盈价格: 0.00000061\n",
|
||||
"止损价格: 0.00000058\n",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "开仓价格: 0.00\n") {
|
||||
t.Fatalf("tiny price rounded to 0.00:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ const (
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
AvgPrice float64
|
||||
Size float64
|
||||
HasAvg bool
|
||||
AvgPrice float64 `json:"avgPrice"`
|
||||
Size float64 `json:"size"`
|
||||
HasAvg bool `json:"hasAvg"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
@@ -26,71 +26,75 @@ type state struct {
|
||||
}
|
||||
|
||||
type Tracker struct {
|
||||
mu sync.Mutex
|
||||
positions map[string]*state
|
||||
applied map[string]Snapshot
|
||||
mu sync.Mutex
|
||||
store positionStore
|
||||
}
|
||||
|
||||
func NewTracker() *Tracker {
|
||||
return &Tracker{
|
||||
positions: make(map[string]*state),
|
||||
applied: make(map[string]Snapshot),
|
||||
}
|
||||
return NewTrackerWithStore(newMemoryStore())
|
||||
}
|
||||
|
||||
func (t *Tracker) Apply(signal *Signal) Snapshot {
|
||||
func NewTrackerWithStore(store positionStore) *Tracker {
|
||||
if store == nil {
|
||||
store = newMemoryStore()
|
||||
}
|
||||
return &Tracker{store: store}
|
||||
}
|
||||
|
||||
func (t *Tracker) Apply(signal *Signal) (Snapshot, error) {
|
||||
if signal == nil {
|
||||
return Snapshot{}
|
||||
return Snapshot{}, nil
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if signal.SignalID != "" {
|
||||
if snap, ok := t.applied[signal.SignalID]; ok {
|
||||
return snap
|
||||
if snap, ok, err := t.store.loadApplied(signal.SignalID); err != nil {
|
||||
return Snapshot{}, err
|
||||
} else if ok {
|
||||
return snap, nil
|
||||
}
|
||||
}
|
||||
|
||||
key := positionKey(signal.StrategyCode, signal.Symbol, signal.Side)
|
||||
action := strings.ToUpper(signal.Action)
|
||||
st := t.positions[key]
|
||||
st, err := t.store.load(key)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
var snap Snapshot
|
||||
del := false
|
||||
switch action {
|
||||
case "OPEN":
|
||||
st = openPosition(signal)
|
||||
snap = snapshotFrom(st)
|
||||
if st != nil {
|
||||
t.positions[key] = st
|
||||
} else {
|
||||
delete(t.positions, key)
|
||||
if st == nil {
|
||||
del = true
|
||||
}
|
||||
case "ADD":
|
||||
st = addPosition(st, signal)
|
||||
snap = snapshotFrom(st)
|
||||
if st != nil {
|
||||
t.positions[key] = st
|
||||
}
|
||||
case "REDUCE":
|
||||
snap = snapshotFrom(st)
|
||||
st = reducePosition(st, signal)
|
||||
if st == nil || st.size <= 0 {
|
||||
delete(t.positions, key)
|
||||
} else {
|
||||
t.positions[key] = st
|
||||
del = true
|
||||
st = nil
|
||||
}
|
||||
case "CLOSE":
|
||||
snap = snapshotFrom(st)
|
||||
delete(t.positions, key)
|
||||
del = true
|
||||
st = nil
|
||||
default:
|
||||
snap = snapshotFrom(st)
|
||||
}
|
||||
|
||||
if signal.SignalID != "" {
|
||||
t.applied[signal.SignalID] = snap
|
||||
if err := t.store.commit(key, st, del, signal.SignalID, snap); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snap
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func openPosition(signal *Signal) *state {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Signal struct {
|
||||
StopLossRatio *float64 `json:"stopLossRatio"`
|
||||
PnL *float64 `json:"pnl"`
|
||||
AccountBalance *float64 `json:"accountBalance"`
|
||||
RawMessage string `json:"rawMessage"`
|
||||
}
|
||||
|
||||
func (s *Signal) ParsedEventTime() time.Time {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
positionKeyPrefix = "notify:position:"
|
||||
appliedKeyPrefix = "notify:position:applied:"
|
||||
positionTTL = 30 * 24 * time.Hour
|
||||
appliedTTL = 7 * 24 * time.Hour
|
||||
storeTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type persistedState struct {
|
||||
Avg float64 `json:"avg"`
|
||||
Size float64 `json:"size"`
|
||||
Mode int `json:"mode"`
|
||||
}
|
||||
|
||||
type positionStore interface {
|
||||
load(key string) (*state, error)
|
||||
commit(key string, st *state, del bool, signalID string, snap Snapshot) error
|
||||
loadApplied(signalID string) (Snapshot, bool, error)
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
positions map[string]*state
|
||||
applied map[string]Snapshot
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{
|
||||
positions: make(map[string]*state),
|
||||
applied: make(map[string]Snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryStore) load(key string) (*state, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.positions[key], nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) loadApplied(signalID string) (Snapshot, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
snap, ok := m.applied[signalID]
|
||||
return snap, ok, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if del {
|
||||
delete(m.positions, key)
|
||||
} else if st != nil {
|
||||
m.positions[key] = st
|
||||
}
|
||||
if signalID != "" {
|
||||
m.applied[signalID] = snap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type kvClient interface {
|
||||
GetRaw(ctx context.Context, key string) ([]byte, error)
|
||||
TxWrite(ctx context.Context, writes []cache.KVWrite) error
|
||||
}
|
||||
|
||||
type redisStore struct {
|
||||
c kvClient
|
||||
}
|
||||
|
||||
func newRedisStore(c *cache.Cache) positionStore {
|
||||
if c == nil {
|
||||
return newMemoryStore()
|
||||
}
|
||||
return &redisStore{c: c}
|
||||
}
|
||||
|
||||
func (s *redisStore) ctx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), storeTimeout)
|
||||
}
|
||||
|
||||
func (s *redisStore) load(key string) (*state, error) {
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
b, err := s.c.GetRaw(ctx, positionKeyPrefix+key)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var p persistedState
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state{avg: p.Avg, size: p.Size, mode: mode(p.Mode)}, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) loadApplied(signalID string) (Snapshot, bool, error) {
|
||||
if signalID == "" {
|
||||
return Snapshot{}, false, nil
|
||||
}
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
b, err := s.c.GetRaw(ctx, appliedKeyPrefix+signalID)
|
||||
if err != nil || len(b) == 0 {
|
||||
return Snapshot{}, false, err
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return Snapshot{}, false, err
|
||||
}
|
||||
return snap, true, nil
|
||||
}
|
||||
|
||||
func (s *redisStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
|
||||
ctx, cancel := s.ctx()
|
||||
defer cancel()
|
||||
writes := make([]cache.KVWrite, 0, 2)
|
||||
posKey := positionKeyPrefix + key
|
||||
if del {
|
||||
writes = append(writes, cache.KVWrite{Key: posKey, Delete: true})
|
||||
} else if st != nil {
|
||||
b, err := json.Marshal(persistedState{Avg: st.avg, Size: st.size, Mode: int(st.mode)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writes = append(writes, cache.KVWrite{Key: posKey, Val: b, TTL: positionTTL})
|
||||
}
|
||||
if signalID != "" {
|
||||
b, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writes = append(writes, cache.KVWrite{Key: appliedKeyPrefix + signalID, Val: b, TTL: appliedTTL})
|
||||
}
|
||||
return s.c.TxWrite(ctx, writes)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
)
|
||||
|
||||
type fakeKV struct {
|
||||
mu sync.Mutex
|
||||
data map[string][]byte
|
||||
fail bool
|
||||
}
|
||||
|
||||
func newFakeKV() *fakeKV {
|
||||
return &fakeKV{data: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (f *fakeKV) GetRaw(_ context.Context, key string) ([]byte, error) {
|
||||
if f.fail {
|
||||
return nil, errors.New("redis down")
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if b, ok := f.data[key]; ok {
|
||||
return append([]byte(nil), b...), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeKV) TxWrite(_ context.Context, writes []cache.KVWrite) error {
|
||||
if f.fail {
|
||||
return errors.New("redis down")
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, w := range writes {
|
||||
if w.Delete {
|
||||
delete(f.data, w.Key)
|
||||
continue
|
||||
}
|
||||
f.data[w.Key] = append([]byte(nil), w.Val...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTrackerPersistsAcrossMemoryInstances(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
mustApply(t, NewTrackerWithStore(store), &Signal{
|
||||
SignalID: "p1", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
|
||||
Action: "OPEN", Quantity: ptr(10), Price: 0.00000059,
|
||||
})
|
||||
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
|
||||
SignalID: "p2", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
|
||||
Action: "ADD", Quantity: ptr(10), Price: 0.00000061,
|
||||
})
|
||||
if math.Abs(snap.AvgPrice-0.0000006) > 1e-12 {
|
||||
t.Fatalf("shared memory store avg=%v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerPersistsAcrossRedisInstances(t *testing.T) {
|
||||
store := &redisStore{c: newFakeKV()}
|
||||
mustApply(t, NewTrackerWithStore(store), &Signal{
|
||||
SignalID: "r1", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
|
||||
Action: "OPEN", Quantity: ptr(10), Price: 100,
|
||||
})
|
||||
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
|
||||
SignalID: "r2", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
|
||||
Action: "ADD", Quantity: ptr(10), Price: 200,
|
||||
})
|
||||
if math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("shared redis store avg=%v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRedisIdempotentAcrossInstances(t *testing.T) {
|
||||
store := &redisStore{c: newFakeKV()}
|
||||
open := &Signal{
|
||||
SignalID: "same", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
||||
}
|
||||
mustApply(t, NewTrackerWithStore(store), open)
|
||||
mustApply(t, NewTrackerWithStore(store), open)
|
||||
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
|
||||
SignalID: "add", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "ADD", Quantity: ptr(1), Price: 200,
|
||||
})
|
||||
if math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("replayed open should not double size, avg=%v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRedisStoreError(t *testing.T) {
|
||||
tr := NewTrackerWithStore(&redisStore{c: &fakeKV{fail: true, data: map[string][]byte{}}})
|
||||
_, err := tr.Apply(&Signal{
|
||||
SignalID: "e1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected store error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPositionStoreError(t *testing.T) {
|
||||
c := &Converter{
|
||||
positions: NewTrackerWithStore(&redisStore{c: &fakeKV{fail: true, data: map[string][]byte{}}}),
|
||||
}
|
||||
_, _, err := c.Convert([]byte(`{"action":"OPEN","symbol":"BTCUSDT","price":1,"quantity":1}`))
|
||||
if !errors.Is(err, ErrPositionStore) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,15 @@ func TestApplyDoesNotChangeMarginRatioOnlySignals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustApply(t *testing.T, tr *Tracker, signal *Signal) Snapshot {
|
||||
t.Helper()
|
||||
snap, err := tr.Apply(signal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func TestAvgPriceOpenAndAdd(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
|
||||
@@ -89,7 +98,7 @@ func TestAvgPriceOpenAndAdd(t *testing.T) {
|
||||
Quantity: ptr(2),
|
||||
Price: 100,
|
||||
}
|
||||
snap := tr.Apply(open)
|
||||
snap := mustApply(t, tr, open)
|
||||
if !snap.HasAvg || snap.AvgPrice != 100 {
|
||||
t.Fatalf("open avg=%v has=%v", snap.AvgPrice, snap.HasAvg)
|
||||
}
|
||||
@@ -103,7 +112,7 @@ func TestAvgPriceOpenAndAdd(t *testing.T) {
|
||||
Quantity: ptr(2),
|
||||
Price: 200,
|
||||
}
|
||||
snap = tr.Apply(add)
|
||||
snap = mustApply(t, tr, add)
|
||||
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("expected avg 150, got %v", snap.AvgPrice)
|
||||
}
|
||||
@@ -121,7 +130,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
|
||||
AmountMarginRatio: ptr(0.1),
|
||||
Price: 100,
|
||||
}
|
||||
tr.Apply(open)
|
||||
mustApply(t, tr, open)
|
||||
|
||||
add := &Signal{
|
||||
SignalID: "r2",
|
||||
@@ -132,7 +141,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
|
||||
AmountMarginRatio: ptr(0.1),
|
||||
Price: 200,
|
||||
}
|
||||
snap := tr.Apply(add)
|
||||
snap := mustApply(t, tr, add)
|
||||
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("expected weighted avg 150, got %v", snap.AvgPrice)
|
||||
}
|
||||
@@ -140,7 +149,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
|
||||
|
||||
func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
tr.Apply(&Signal{
|
||||
mustApply(t, tr, &Signal{
|
||||
SignalID: "c1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
@@ -150,7 +159,7 @@ func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
|
||||
Price: 64000,
|
||||
})
|
||||
|
||||
snap := tr.Apply(&Signal{
|
||||
snap := mustApply(t, tr, &Signal{
|
||||
SignalID: "c2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
@@ -162,7 +171,7 @@ func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
|
||||
t.Fatalf("close should report entry avg 64000, got %v", snap.AvgPrice)
|
||||
}
|
||||
|
||||
snap = tr.Apply(&Signal{
|
||||
snap = mustApply(t, tr, &Signal{
|
||||
SignalID: "c3",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
@@ -187,10 +196,10 @@ func TestSignalIDIdempotent(t *testing.T) {
|
||||
Quantity: ptr(1),
|
||||
Price: 100,
|
||||
}
|
||||
tr.Apply(sig)
|
||||
tr.Apply(sig)
|
||||
mustApply(t, tr, sig)
|
||||
mustApply(t, tr, sig)
|
||||
|
||||
snap := tr.Apply(&Signal{
|
||||
snap := mustApply(t, tr, &Signal{
|
||||
SignalID: "dup2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
@@ -206,11 +215,11 @@ func TestSignalIDIdempotent(t *testing.T) {
|
||||
|
||||
func TestDifferentSideIsolated(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
tr.Apply(&Signal{
|
||||
mustApply(t, tr, &Signal{
|
||||
SignalID: "l1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
||||
})
|
||||
snap := tr.Apply(&Signal{
|
||||
snap := mustApply(t, tr, &Signal{
|
||||
SignalID: "s1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "SHORT",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 200,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package tz
|
||||
|
||||
import "time"
|
||||
|
||||
var CST = time.FixedZone("CST", 8*3600)
|
||||
|
||||
func Format(t time.Time, layout string) string {
|
||||
return t.In(CST).Format(layout)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE notification_rule DROP INDEX uk_name;
|
||||
ALTER TABLE notification_rule DROP COLUMN name;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE notification_rule
|
||||
ADD COLUMN name VARCHAR(64) NOT NULL DEFAULT '' AFTER id;
|
||||
|
||||
UPDATE notification_rule SET name = CONCAT('rule-', id) WHERE name = '';
|
||||
|
||||
ALTER TABLE notification_rule
|
||||
ADD UNIQUE KEY uk_name (name);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE notification_rule
|
||||
DROP INDEX idx_source_event,
|
||||
ADD UNIQUE KEY uk_source_event (source_id, event);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE notification_rule
|
||||
DROP INDEX uk_source_event,
|
||||
ADD INDEX idx_source_event (source_id, event);
|
||||
@@ -33,23 +33,23 @@ type fixture struct {
|
||||
chEmail string
|
||||
}
|
||||
|
||||
func requireE2EEnv(t *testing.T) (base, adminKey, dingWebhook, dingSecret, barkURL, emailTo string) {
|
||||
func requireE2EEnv(t *testing.T) (base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo string) {
|
||||
t.Helper()
|
||||
base = envOr("E2E_BASE_URL", "http://82.157.251.93:8080")
|
||||
adminKey = envOr("E2E_ADMIN_KEY", "admin-sk-change-me")
|
||||
dingWebhook = os.Getenv("E2E_DINGTALK_WEBHOOK")
|
||||
dingAccessToken = os.Getenv("E2E_DINGTALK_ACCESS_TOKEN")
|
||||
dingSecret = os.Getenv("E2E_DINGTALK_SECRET")
|
||||
barkURL = os.Getenv("E2E_BARK_URL")
|
||||
emailTo = os.Getenv("E2E_EMAIL_TO")
|
||||
if dingWebhook == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
|
||||
t.Skip("missing E2E_DINGTALK_WEBHOOK / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
|
||||
if dingAccessToken == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
|
||||
t.Skip("missing E2E_DINGTALK_ACCESS_TOKEN / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func setupFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
@@ -81,7 +81,7 @@ func setupFixture(t *testing.T) *fixture {
|
||||
|
||||
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||
"name": f.chDing, "type": "dingtalk", "status": 1,
|
||||
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
|
||||
"config": map[string]string{"access_token": dingAccessToken, "secret": dingSecret},
|
||||
}, http.StatusCreated)
|
||||
f.dingID = intFrom(ding["id"])
|
||||
t.Cleanup(func() {
|
||||
@@ -116,6 +116,7 @@ func setupFixture(t *testing.T) *fixture {
|
||||
})
|
||||
|
||||
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
|
||||
"name": "e2e-rule-" + suffix,
|
||||
"source_name": f.srcName,
|
||||
"event": "trade.open",
|
||||
"template_name": tmplName,
|
||||
@@ -287,7 +288,7 @@ func TestNotifyFlow_DisableRule(t *testing.T) {
|
||||
// TestNotifyFlow_DifferentTemplatesPerRule verifies rule→template binding:
|
||||
// same source, three events, each with a distinct template and a single channel.
|
||||
func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
srcName := "e2e-mt-" + suffix
|
||||
@@ -319,7 +320,7 @@ func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
||||
marker: "DING-TMPL",
|
||||
chName: "e2e-mtd-" + suffix,
|
||||
chType: "dingtalk",
|
||||
chCfg: map[string]any{"webhook_url": dingWebhook, "secret": dingSecret},
|
||||
chCfg: map[string]any{"access_token": dingAccessToken, "secret": dingSecret},
|
||||
tmpl: "DING-TMPL {{.symbol}} ding price={{.price}}",
|
||||
},
|
||||
{
|
||||
@@ -361,6 +362,7 @@ func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
||||
})
|
||||
|
||||
rule := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/rules", map[string]any{
|
||||
"name": "e2e-rule-" + r.event + "-" + suffix,
|
||||
"source_name": srcName,
|
||||
"event": r.event,
|
||||
"template_name": tmplName,
|
||||
|
||||
Reference in New Issue
Block a user