Files
aiaa-notification-server/internal/subscriber/cryptostrategy/convert_test.go
T
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

417 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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)
}
}
}