Compare commits

...

30 Commits

Author SHA1 Message Date
ryan 3251831118 feat(钉钉通知): 增强标题生成逻辑以优先使用内容首行
Changes:
* 新增 dingtalkTitle 函数,优先使用内容的第一行作为标题,若内容为空则回退到默认标题
* 更新 Send 方法以使用新的标题生成逻辑
* 添加单元测试以验证标题生成的正确性
2026-08-28 10:30:52 +08:00
ryan 219a850d93 fix(钉钉通知): 修复 Markdown 消息换行被折叠导致排版错乱的问题
Motivation:
钉钉 Markdown 渲染时会将单个换行符折叠为空格,导致推送的通知内容被压缩成一行,严重影响消息可读性。

Changes:

* 发送前将消息正文中的单个换行统一转换为段落分隔(\n\n),确保模板换行在钉钉中正确渲染
* 归一化 \r\n 与 \r 换行,并合并已有连续换行,避免产生多余空行
* 新增换行转换逻辑的单元测试
2026-08-27 16:58:41 +08:00
ryan 4d4dbd27b2 feat(去重逻辑): 增强信号去重机制以区分不同动作
Changes:
* 更新去重键计算逻辑,新增动作字段以区分不同的交易动作(如 REDUCE 和 CLOSE)
* 增加单元测试以验证不同动作在相同价格下的去重行为
* 更新文档以反映去重窗口的变化,确保准确性
2026-08-25 14:12:34 +08:00
ryan b396638438 feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation:
持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。

Changes:

* 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化
* 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入
* 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真
* 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖
* 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
2026-08-23 14:43:53 +08:00
ryan f8e74738cc feat(价格展示): 避免低价资产被格式化丢失为 0.00
Motivation:
交易通知等展示场景中,低价资产(如 0.00000059)按保留两位小数的格式渲染会被截断为 0.00,用户无法读取真实价格;需要在不改变存量数值类型和既有模板写法的前提下,保证任意精度价格都能正确展示。

Changes:

* 新增价格展示格式化能力:数值不小于 1 时保留两位小数,小于 1 时输出最短精确值
* 新增 Decimal 类型实现自定义格式化,使模板中的浮点值不再坍缩为 0.00 且不产生科学计数法
* 新增模板数据渲染期数值包装能力,不修改原始数据,保证条件判断与既有模板语法兼容
* 补充价格格式化、精度保留及数据不可变性的单元测试
2026-08-23 00:17:43 +08:00
ryan 6f5aaa9f85 fix(价格展示): 修复模板渲染时极小价格被折叠为 0.00 的问题
Motivation:
此前的修复仅覆盖直接拼接文案的场景,跟单等基于模板渲染的推送场景中,%.2f 格式化仍会把 PEPE 等 Meme 币的极小价格(如 0.00000059)折叠为 0.00,导致交易通知丢失真实价格、误导用户。

Changes:

* 抽取共享的价格显示逻辑到统一显示层,两个订阅者改为复用,移除重复实现
* 新增 Decimal 类型,渲染时以副本方式包装模板数据,使模板内 printf 风格格式化保留极小价格精度且不出现科学计数法,同时不修改调用方原始数据
* 渲染引擎空值判断改用反射实现,覆盖全部整型、无符号整型与浮点类型
* 补充转换到渲染的端到端回归测试,覆盖极小价格精度、科学计数法与数据不可变性
2026-08-23 00:12:41 +08:00
ryan c7e154d7cc feat(钉钉通道): 支持仅使用 access_token 配置钉钉机器人
Motivation:
钉钉机器人的 webhook 前缀固定不变,要求用户填写完整 URL 冗余且容易出错。简化通道配置项,降低接入配置成本,同时保证存量配置不受影响。

Changes:

* 钉钉通道配置改用 access_token,webhook 前缀由内部固定拼接
* 保留对旧 webhook_url 配置的兼容,access_token 存在时优先生效
* 新增 webhook 地址解析逻辑的单元测试,覆盖优先级与回退场景
* 同步更新 README、HTTP 请求示例及设计文档中的配置示例
* E2E 测试改用 E2E_DINGTALK_ACCESS_TOKEN 环境变量并按新格式创建通道
2026-08-19 15:14:38 +08:00
ryan cd3db8d453 fix(交易信号): 修复极小价格展示精度丢失与策略覆盖匹配失效
Motivation:
部分 meme 币等标的价格极小(如 0.00000059),原有格式化统一保留两位小数会将其显示为 0.00,导致推送消息中的价格信息失真、误导用户;同时 viper 加载配置时会将嵌套 map 的 key 统一转为小写,导致按大写策略编码配置的策略覆盖项无法命中,仓位倍数、杠杆等覆盖参数失效。

Changes:

* 新增价格展示格式化逻辑:绝对值不小于 1 的数值保留两位小数,小于 1 的数值采用最短精确表示,避免极小价格被截断为 0.00
* 策略覆盖查找改为大小写不敏感匹配,兼容 viper 将配置 key 小写化的行为,确保策略编码以任意大小写配置均可生效
* 补充极小价格展示、配置加载解析及策略覆盖匹配的单元测试
2026-08-19 01:03:29 +08:00
ryan 7651c57536 feat(规则管理): 规则查询接口返回关联通知渠道
Motivation:
前端在展示规则详情与规则列表时,需要同时看到每条规则绑定了哪些通知渠道以及各渠道的启用状态。此前规则接口只返回规则本身,渠道绑定信息需要额外请求才能获取,增加了交互成本。本次让规则读取接口一次性携带关联渠道信息。

Changes:

* 规则模型新增 Channels 字段及渠道条目结构,包含渠道标识、名称、类型和按规则维度的启用开关
* 创建、查询单条、列表查询规则接口在返回结果时填充绑定的渠道信息,无绑定时返回空列表
* 新增批量查询规则与渠道绑定关系的数据访问能力,按规则聚合返回,避免列表场景下逐条查询
* 单个渠道数据读取失败时跳过该条目,不阻断整体结果返回
2026-08-18 14:09:02 +08:00
ryan 5eebddc680 feat(消息订阅): 新增消息接收原始报文日志
Motivation:
消息在分发处理前缺少接收环节的观测信息,排查消息丢失、重复消费或报文异常等问题时难以还原现场,需要在消息入口处记录完整的原始信息以支撑问题定位与链路追踪。

Changes:

* 在消息处理入口记录原始报文的接收日志
* 日志包含订阅者名称、队列、来源及投递标签等上下文信息
* 完整输出消息体内容,便于还原消息接收现场
2026-08-18 11:43:20 +08:00
ryan 155a54f536 fix(crypto策略): 订阅路由键改为通配符以覆盖来源维度的信号
Motivation:
去重键已加入来源维度,信号会以 strategy.signal.origin 路由键发布,原精确匹配策略将漏收该类消息,需扩展订阅范围。

Changes:

* 将 routing_key 由 strategy.signal 改为 strategy.signal.# 通配符
* 新增注释说明通配符覆盖 strategy.signal 与 strategy.signal.origin
2026-08-17 17:01:37 +08:00
ryan f7ea2e7cf9 feat(crypto策略): 支持巴菲特策略纯 rawMessage 信号推送
Motivation:
新增巴菲特激进/稳健策略(PUTEJJ/PUTEWJ)的跟单推送能力。该类信号仅有 rawMessage、无 action 字段,需按原文直接推送,并过滤启动文案、本地化时间字段。

Changes:

* 支持无 action 的纯 rawMessage 信号,映射为 trade.message 事件
* 新增 not_contains 条件操作符,用于过滤启动文案
* 新增 replace 模板函数,将 Time: 替换为推送时间:
* 补充巴菲特策略模板、规则与渠道配置文档
2026-08-17 16:38:09 +08:00
ryan 33b2a96bcf fix(消息去重): 去重键加入来源维度,避免跨来源信号被误去重
Motivation:
不同来源(如 crypto-strategy 与 trade-signal)可能产生策略、币种、周期、方向、价格完全一致的信号,旧去重键仅按这五维计算,导致后到的不同来源信号被误判为重复而漏推,无法支撑跟单策略等新增来源并行推送。

Changes:

* 信号去重键新增来源(source)维度,不同来源的相同信号不再互相去重
* 信号哈希计算时透传来源名称,同一来源内的重复消息仍正常去重
* 补充跟单策略文案模板的渲染测试与接口创建文档
2026-08-17 15:39:20 +08:00
ryan a2c0590353 feat(crypto策略): 新增 AG 趋势、异动与波段跟踪信号支持
Motivation:
crypto-strategy 源新增 AG 趋势(AGTS)、异动(AMA)、波段跟踪(BTS) 三类量化策略信号,需与既有 trade.* 事件区分以避免和 AI crypto signals 精确匹配冲突,并为缺省方向提供兜底推导。

Changes:

* 事件名映射按 strategyCode 使用独立前缀(AGTS./AMA./BTS.,HLSS 保持不变)
* direction/side 缺失时依据 isSale 兜底推导为 LONG/SHORT
* 补充三类策略的模板、规则示例文档与渲染/转换测试
2026-08-17 11:07:34 +08:00
ryan a93078dc00 fix(时区): 推送时间统一使用 UTC+8 而非服务器本地时区
Motivation:
推送时间原先依赖 time.Local,部署在不同时区的服务器会产生不一致的展示结果。统一为 UTC+8,确保所有订阅者看到的时间固定为北京时间。

Changes:

* 新增 internal/tz 时区工具,集中定义 UTC+8 与格式化逻辑
* 通知、crypto 策略、交易信号的时间渲染统一改用 UTC+8
* 补充测试断言 pushedAt 为 UTC+8 当前时间
* 更新 README 中关于注入时间的说明

Breaking Changes:
所有推送时间字段将按 UTC+8 展示,若服务部署在非 UTC+8 时区且依赖原本地时间行为,输出会发生变化。
2026-08-17 01:29:13 +08:00
ryan d2e8476398 feat(规则): 支持同一事件多规则按条件区分并全部发送
Motivation:
止盈、止损等不同策略信号会映射到同一事件(如 trade.close),但原有唯一约束要求每个事件只能有一条规则,无法按策略区分处理。放开该约束后,同一事件可配置多条规则,通过规则条件与精确/通配优先级区分,命中条件的规则全部发送;同时将去重键从消息原文改为信号维度,避免同一信号因时间戳等无关字段差异被误判为重复。

Changes:

* 移除规则 source_id+event 的唯一约束,改为普通索引
* 事件匹配改为返回命中优先级内所有启用规则,并按 ID 逐条派发
* 通知服务遍历多条规则,按条件过滤后聚合发送渠道
* 去重键由消息 body 哈希改为策略/币种/周期/方向/价格信号维度哈希
2026-08-17 01:20:49 +08:00
ryan f5f64f7653 feat(crypto策略): 支持 AI 信号止盈止损事件与多字段渲染
Motivation:
区分止盈与止损事件,并为 AI crypto signals 策略提供更丰富的推送内容渲染能力(杠杆、止盈档位、收益、持仓周期等)。

Changes:

* 开仓(多/空,含原 isSale 空单)统一映射为 trade.open,不再发 trade.sell
* 新增 trade.gain 事件用于止盈,止损仍映射为 trade.close;HLSS 策略保持原有事件映射
* 新增 leverageText、tp1~tp5、closeAction、revenueDisplay、holdPeriod 等渲染字段
* 新增收益符号、止盈档位、持仓周期等格式化逻辑
* 补充 AI crypto signals 的模板与规则文档及单元测试
2026-08-17 00:54:01 +08:00
ryan 0f9f154969 feat(规则): 支持名称代号,便于区分和管理
创建与更新规则时必填全局唯一 name,已有规则迁移回填为 rule-{id}。
2026-08-17 00:01:34 +08:00
ryan b3c42af53b feat(订阅): 支持 HLSS 策略止盈与介入区间格式化
Motivation:
HLSS 策略信号携带的止盈价位列表与介入价位含冗余小数位,直接推送可读性差,需转换为紧凑区间并补齐推送时间,提升通知的信息密度与可读性。

Changes:

* 新增止盈目标区间,将止盈价位列表去零后连接为区间
* 新增介入区间,由开仓价与介入价组合生成
* 压缩价格数值多余小数位,保留原始精度
* 注入推送时间,便于模板渲染提醒时间
2026-08-16 23:21:59 +08:00
ryan 14504af950 feat(通知): SafeW 只发正文并注入推送时间
去掉渠道标题前缀,模板可用 pushedAt 显示本地推送时间。
2026-08-16 01:18:27 +08:00
ryan a9b6234208 feat(通知模板): 支持 case 取值映射与事件名匹配渲染
Motivation:
让通知模板能够将 action、event 等原始枚举值映射为可读中文文案,并支持按事件名后缀匹配;同时简化 SafeW 消息发送格式,避免 Markdown 转义引入的显示问题。

Changes:

* 新增 `case` 模板函数,按值匹配 key/文案并支持末尾奇数参数作为默认值
* `case` 匹配兼容事件名后缀(如 trade.close 匹配 .close)
* `line` 前缀自动去除末尾冒号,避免重复冒号
* 模板渲染前自动注入 event 字段,便于模板按事件名取值
* SafeW 消息改为纯文本发送,移除 MarkdownV2 转义
2026-08-16 00:45:27 +08:00
ryan ae25409e56 feat(通知模板): 支持缺字段按空值渲染与空行省略
Motivation:
统一 crypto-strategy 与 trade-signal 两类交易信号的模板字段,使同一套通知模板可复用;字段缺失或为空时不再导致渲染报错或输出空行,通知内容更整洁。

Changes:

* 渲染器缺失字段改为按空值处理,新增 line 函数实现空值整行省略
* crypto-strategy 信号补充止盈价、止损价、平均价、收益额等模板字段
* trade-signal 信号保留原始报文中的额外字段以适配统一模板
* 修复结构化日志将 error 作为值直接输出导致的格式问题
2026-08-16 00:19:50 +08:00
ryan de7a52e81f feat(规则匹配): 事件名支持通配符匹配
Motivation:
通知规则的事件名此前仅支持精确匹配,无法用一条规则覆盖多类事件(如 trade.* 下的全部交易事件),规则配置维护成本高。本次引入通配符匹配,让规则可按模式批量命中事件,同时保证精确规则优先。

Changes:

* 事件名支持 * 和 ? 通配符匹配
* 匹配优先级:精确匹配 > 更具体的通配 > 泛匹配,同分时取更小 ID
* 精确命中未命中时回退为列出源下启用规则并按优先级挑选
* 抽取缓存预热逻辑为独立方法
* 更新文档说明事件名通配规则
2026-08-15 23:39:00 +08:00
ryan dc313fda13 feat(订阅): 新增 crypto-strategy 信号格式化器
Motivation:
crypto-strategy 交易信号采用 envelope 外壳与字符串化/嵌套的 payload,原有 trade_signal 格式化器无法解析,需要独立的转换逻辑以生成通知文本和事件数据。

Changes:

* 新增 crypto_strategy 消息转换器,解析 envelope 及嵌套、字符串化、扁平三种 payload 形态
* 根据平仓/止盈/卖出等标志推断交易动作,生成对应事件类型与中文通知文本
* 提取订单号、策略代码、价格、杠杆等字段用于通知数据
* 将消息转换器抽象为接口,订阅器按 formatter 配置选择对应实现
* 更新配置校验以支持 crypto_strategy 格式化器,并补充单元测试
2026-08-15 23:04:39 +08:00
ryan d6c416a543 feat(订阅): 新增 crypto-strategy 交易信号通知订阅
Motivation:
支持加密货币策略的交易信号通过独立队列推送通知,避免与 executor 队列的 consumer.strategy 争抢消息。

Changes:

* 新增 crypto-strategy 订阅配置,使用独立的信号通知队列和死信队列
* 配置独立 topic 交换机与 strategy.signal 路由键
* 复用 trade_signal 格式化器处理信号内容

Breaking Changes:
无
2026-08-15 21:40:48 +08:00
ryan 0210b70bda perf(日志): 空轮询时不输出 Info 日志以减少噪音
Motivation:
轮询安全网关更新时,绝大多数周期没有新消息,持续输出 Info 日志会产生大量噪音并带来不必要的开销。仅在确有更新时才记录日志,便于聚焦真实事件。

Changes:

* 仅在存在更新时输出 getUpdates 日志
* 仅在拉取到群聊数据时输出 poll 日志
2026-08-15 18:19:41 +08:00
ryan 6bdd3eb786 feat(订阅配置): 支持环境变量展开并校验订阅连接地址
Motivation:
订阅连接地址中的敏感凭据不应硬编码到配置文件,改为通过环境变量注入,并自动跳过未配置或非 AMQP 协议的订阅,避免无效连接导致启动失败。

Changes:

* 订阅 URL 加载时展开环境变量并去除首尾空白
* 新增 AMQP 协议校验,仅 amqp/amqps 地址视为有效连接
* 非 AMQP 协议的订阅(如未配置的占位符)不再参与激活
* docker-compose 环境变量改用映射格式并加引号,规避特殊字符解析问题
* 补充环境变量展开与占位符跳过相关的单元测试
2026-08-15 17:57:09 +08:00
ryan 4fe9f83b89 chore(订阅): 重连错误日志补充 Broker 地址
Motivation:
多 Broker 部署场景下,订阅者重连失败时仅记录错误名称,难以快速定位是哪一台 Broker 连接异常。补充 Broker 地址并规范化错误字段,提升故障排查效率。

Changes:

* 重连失败日志新增 broker host 字段
* 新增 brokerHost 函数,从连接 URL 解析并输出主机地址
* 错误字段改为记录错误信息字符串,避免直接序列化 error 对象
2026-08-15 17:49:31 +08:00
ryan 7a7e69c69a chore(部署): 为通知服务配置 RabbitMQ 连接地址
Motivation:
补齐 RabbitMQ 订阅功能的运行依赖,使服务在部署环境中能够连接云端消息队列并接收交易信号。

Changes:

* 在 docker-compose 中新增 RABBITMQ_URL 环境变量
* 指定 AMQPS 加密连接及云端实例认证信息
2026-08-15 17:45:45 +08:00
ryan ab295d6d28 feat: log raw MQ body after dedup
Keep the original payload on post-dedup handling logs so accepted, filtered, and error paths can be traced.
2026-08-15 17:37:14 +08:00
52 changed files with 3670 additions and 255 deletions
+33 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 signalscrypto-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
止盈目标:
TP10.1241
TP20.1247
TP30.1253
TP40.1259
TP50.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
}'
```
+3 -3
View File
@@ -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..."
}
}
+49 -7
View File
@@ -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&timestamp=%d&sign=%s", cfg.WebhookURL, timestamp, sign)
reqURL = fmt.Sprintf("%s&timestamp=%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)
+57
View File
@@ -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)
}
})
}
}
+6 -18
View File
@@ -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"`
+3 -29
View File
@@ -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)
}
+46
View File
@@ -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
}
+5
View File
@@ -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
+13
View File
@@ -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")
}
}
+10 -3
View File
@@ -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)
}
}
+90 -1
View File
@@ -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 {
+113
View File
@@ -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
}
}
+52
View File
@@ -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"])
}
}
+74
View File
@@ -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
}
+91
View File
@@ -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)
}
}
+8 -38
View File
@@ -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
}
+78 -2
View File
@@ -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 "labelvalue\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
}
}
}
+215 -4
View File
@@ -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"])
}
}
+78 -1
View File
@@ -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,
+43
View File
@@ -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())
}
}
+11
View File
@@ -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
View File
@@ -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 {
+137 -6
View File
@@ -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)
}
}
+3 -1
View File
@@ -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
View File
@@ -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",
"TP10.1241",
"TP20.1247",
"TP30.1253",
"TP40.1259",
"TP50.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)
}
}
}
+51
View File
@@ -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{}
+188
View File
@@ -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
+36 -14
View File
@@ -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
+22
View File
@@ -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{
+40 -7
View File
@@ -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 {
+37 -11
View File
@@ -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)
}
}
+16 -14
View File
@@ -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)
}
}
+33 -29
View File
@@ -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 {
+145
View File
@@ -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,
})
+9
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE notification_rule DROP INDEX uk_name;
ALTER TABLE notification_rule DROP COLUMN name;
+7
View File
@@ -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);
+10 -8
View File
@@ -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,