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

436 lines
14 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 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 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)
}
}
}