From f5f64f765309d93d78d5f3e8c3ddf64547e75bb2 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 00:54:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(crypto=E7=AD=96=E7=95=A5):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20AI=20=E4=BF=A1=E5=8F=B7=E6=AD=A2=E7=9B=88=E6=AD=A2?= =?UTF-8?q?=E6=8D=9F=E4=BA=8B=E4=BB=B6=E4=B8=8E=E5=A4=9A=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: 区分止盈与止损事件,并为 AI crypto signals 策略提供更丰富的推送内容渲染能力(杠杆、止盈档位、收益、持仓周期等)。 Changes: * 开仓(多/空,含原 isSale 空单)统一映射为 trade.open,不再发 trade.sell * 新增 trade.gain 事件用于止盈,止损仍映射为 trade.close;HLSS 策略保持原有事件映射 * 新增 leverageText、tp1~tp5、closeAction、revenueDisplay、holdPeriod 等渲染字段 * 新增收益符号、止盈档位、持仓周期等格式化逻辑 * 补充 AI crypto signals 的模板与规则文档及单元测试 --- README.md | 2 + docs/httpie/curls.md | 136 +++++++++++++ internal/subscriber/cryptostrategy/convert.go | 101 +++++++++- .../subscriber/cryptostrategy/convert_test.go | 179 +++++++++++++++++- 4 files changed, 412 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c6a5971..01cf38a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ make build && ./bin/server 环境变量 `RABBITMQ_URL` 未设置时不启动消费,HTTP 通知不受影响。交易信号订阅需事先创建 Source(如 `trade-signal`)、模板(可用 `{{.formatted}}`)、规则 `trade.open` / `trade.add` / `trade.close` / `trade.reduce`、以及渠道。规则条件可用 `strategyCode` / `symbol` / `period`。 +`crypto-strategy` 开仓(多/空,含原来的 `isSale` 空单)都映射为 `trade.open`,不再发 `trade.sell`。止盈(`isGain`)为 `trade.gain`,止损(`isClose` 且非 `isGain`)为 `trade.close`。高低分 `HLSS` 仍用 `HLSS.open` / `HLSS.sell` / `HLSS.close`。同一 Source 每个 event 只能有一条规则,所以止盈不能再和止损共用 `trade.close`。AI crypto signals 的现成模板与规则见 `docs/httpie/curls.md`。 + 健康检查:`GET /health` → `{"status":"ok"}` --- diff --git a/docs/httpie/curls.md b/docs/httpie/curls.md index d519758..edd81ad 100644 --- a/docs/httpie/curls.md +++ b/docs/httpie/curls.md @@ -429,3 +429,139 @@ curl -X PATCH 'http://localhost:8080/api/v1/rules/1/channels/1/disable' \ curl -X GET 'http://localhost:8080/api/v1/message-logs?source=trading-system&event=trade.open&status=success&page=1&page_size=20' \ -H 'Authorization: Bearer admin-sk-change-me' ``` + +--- + +## AI crypto signals(crypto-strategy) + +Source:`crypto-strategy`。渠道:`safew_ai_crypto_signals`。条件:`strategyCode = ai-crypto-signals`。 + +事件:开仓(多/空)→ `trade.open`;止盈 → `trade.gain`;止损 → `trade.close`。空单开仓不再发 `trade.sell`。 + +开仓渲染示例: + +``` +预警时间:2026-08-16 03:20:13 +预警币种:APE +交易方向:做多 +建议杠杆:31x +入场区域:0.1235 +风险控制(止损):0.1223 +止盈目标: +TP1:0.1241 +TP2:0.1247 +TP3:0.1253 +TP4:0.1259 +TP5:0.1265 +推送时间:2026-08-16 03:20:13 +``` + +止盈渲染示例: + +``` +止盈时间:2026-08-16 16:29:23 +预警币种:LTC +执行操作:到达第一止盈 (TP1) +平仓点位:44.63 +预警收益:+14.2793% +预警周期:53分钟 +``` + +止损渲染示例: + +``` +止损时间:2026-08-16 16:17:15 +预警币种:ETH +执行操作:触发止损 +平仓点位:1887 +最终损益:-30.1557% +``` + +### 创建开仓模板 + +```bash +curl -X POST 'http://localhost:8080/api/v1/templates' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "ai_crypto_signals_open", + "content": "预警时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n交易方向:{{case .side \"LONG\" \"做多\" \"SHORT\" \"做空\"}}\n{{line \"建议杠杆\" .leverageText}}入场区域:{{.entryRange}}\n{{line \"风险控制(止损)\" .stopLossPrice}}止盈目标:\n{{with .tp1}}TP1:{{.}}\n{{end}}{{with .tp2}}TP2:{{.}}\n{{end}}{{with .tp3}}TP3:{{.}}\n{{end}}{{with .tp4}}TP4:{{.}}\n{{end}}{{with .tp5}}TP5:{{.}}\n{{end}}推送时间:{{.pushedAt}}" + }' +``` + +### 创建止盈模板 + +```bash +curl -X POST 'http://localhost:8080/api/v1/templates' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "ai_crypto_signals_gain", + "content": "止盈时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n执行操作:{{.closeAction}}\n平仓点位:{{.price}}\n预警收益:{{.revenueDisplay}}\n{{line \"预警周期\" .holdPeriod}}" + }' +``` + +### 创建止损模板 + +```bash +curl -X POST 'http://localhost:8080/api/v1/templates' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "ai_crypto_signals_close", + "content": "止损时间:{{.pushedAt}}\n预警币种:{{.symbol}}\n执行操作:触发止损\n平仓点位:{{.price}}\n最终损益:{{.revenueDisplay}}\n{{line \"预警周期\" .holdPeriod}}" + }' +``` + +### 创建开仓 / 止盈 / 止损规则 + +```bash +curl -X POST 'http://localhost:8080/api/v1/rules' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "AI crypto signals 开仓", + "source_name": "crypto-strategy", + "event": "trade.open", + "template_name": "ai_crypto_signals_open", + "channels": ["safew_ai_crypto_signals"], + "conditions": [ + {"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"} + ], + "enabled": 1 + }' +``` + +```bash +curl -X POST 'http://localhost:8080/api/v1/rules' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "AI crypto signals 止盈", + "source_name": "crypto-strategy", + "event": "trade.gain", + "template_name": "ai_crypto_signals_gain", + "channels": ["safew_ai_crypto_signals"], + "conditions": [ + {"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"} + ], + "enabled": 1 + }' +``` + +```bash +curl -X POST 'http://localhost:8080/api/v1/rules' \ + -H 'Authorization: Bearer admin-sk-change-me' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "AI crypto signals 止损", + "source_name": "crypto-strategy", + "event": "trade.close", + "template_name": "ai_crypto_signals_close", + "channels": ["safew_ai_crypto_signals"], + "conditions": [ + {"field": "strategyCode", "op": "eq", "value": "ai-crypto-signals"} + ], + "enabled": 1 + }' +``` diff --git a/internal/subscriber/cryptostrategy/convert.go b/internal/subscriber/cryptostrategy/convert.go index b2de3a8..01ff3e2 100644 --- a/internal/subscriber/cryptostrategy/convert.go +++ b/internal/subscriber/cryptostrategy/convert.go @@ -38,6 +38,7 @@ type payload struct { type remark struct { OrderID string `json:"orderId"` Revenue string `json:"revenue"` + Period string `json:"period"` } type Converter struct{} @@ -99,12 +100,28 @@ func Convert(body []byte) (string, map[string]interface{}, error) { if p.TotalGainTarget != 0 { data["totalGainTarget"] = p.TotalGainTarget } - if r := parseRemark(p.Remark); r.OrderID != "" || r.Revenue != "" { + 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 @@ -153,13 +170,23 @@ func mergePayloadFields(data map[string]interface{}, payloadJSON []byte) { } 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.IsClose: - return "CLOSE" case p.IsGain: return "GAIN" - case p.IsSale: - return "SELL" + case p.IsClose: + return "CLOSE" default: return "OPEN" } @@ -320,3 +347,67 @@ func firstNonEmpty(a, b string) string { } 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) + } +} diff --git a/internal/subscriber/cryptostrategy/convert_test.go b/internal/subscriber/cryptostrategy/convert_test.go index d8094cb..d88ae95 100644 --- a/internal/subscriber/cryptostrategy/convert_test.go +++ b/internal/subscriber/cryptostrategy/convert_test.go @@ -152,7 +152,7 @@ func TestConvertRendersSharedSignalTemplate(t *testing.T) { if err != nil { t.Fatalf("render: %v", err) } - if !strings.Contains(out, "ICP") || !strings.Contains(out, "CLOSE") { + if !strings.Contains(out, "ICP") || !strings.Contains(out, "GAIN") { t.Fatalf("out=%s", out) } } @@ -237,3 +237,180 @@ func TestConvertInvalidJSON(t *testing.T) { t.Fatal("expected error") } } + +func TestConvertSaleIsOpenNotSell(t *testing.T) { + body := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"LTC\",\"isSale\":true,\"isClose\":false,\"isGain\":false,\"gainTarget\":5,\"price\":44.68,\"lossPrice\":44.91,\"gainPrices\":\"44.565,44.45,44.335,44.22,44.105\",\"leverage\":58}", + "eventTime":1786894571102 + }`) + event, data, err := Convert(body) + if err != nil { + t.Fatal(err) + } + if event != "trade.open" { + t.Fatalf("event=%q want trade.open", event) + } + formatted, _ := data["formatted"].(string) + if !strings.Contains(formatted, "空单开仓") { + t.Fatalf("formatted=%s", formatted) + } + if strings.Contains(formatted, "空单卖出") { + t.Fatalf("SHORT open must not say 卖出:\n%s", formatted) + } +} + +func TestConvertTakeProfitIsGainEvent(t *testing.T) { + body := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isSale\":true,\"isClose\":true,\"isGain\":true,\"gainTarget\":1,\"price\":44.63,\"remark\":\"{\\\"orderId\\\":\\\"x\\\",\\\"revenue\\\":\\\"14.2793%\\\",\\\"period\\\":\\\"53 min\\\"}\"}", + "eventTime":1786897763403 + }`) + event, data, err := Convert(body) + if err != nil { + t.Fatal(err) + } + if event != "trade.gain" { + t.Fatalf("event=%q want trade.gain", event) + } + if data["holdPeriod"] != "53分钟" { + t.Fatalf("holdPeriod=%v", data["holdPeriod"]) + } + if data["revenueDisplay"] != "+14.2793%" { + t.Fatalf("revenueDisplay=%v", data["revenueDisplay"]) + } + if data["closeAction"] != "到达第一止盈 (TP1)" { + t.Fatalf("closeAction=%v", data["closeAction"]) + } +} + +func TestConvertStopLossIsCloseEvent(t *testing.T) { + body := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"ETH","direction":"SHORT", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isSale\":true,\"isClose\":true,\"isGain\":false,\"price\":1887,\"remark\":\"{\\\"orderId\\\":\\\"x\\\",\\\"revenue\\\":\\\"30.1557%%\\\",\\\"period\\\":\\\"signal\\\"}\"}", + "eventTime":1786897035113 + }`) + event, data, err := Convert(body) + if err != nil { + t.Fatal(err) + } + if event != "trade.close" { + t.Fatalf("event=%q want trade.close", event) + } + if data["revenueDisplay"] != "-30.1557%" { + t.Fatalf("revenueDisplay=%v", data["revenueDisplay"]) + } + if _, ok := data["holdPeriod"]; ok { + t.Fatalf("holdPeriod should be omitted for period=signal, got %v", data["holdPeriod"]) + } +} + +const aiCryptoOpenTmpl = `预警时间:{{.pushedAt}} +预警币种:{{.symbol}} +交易方向:{{case .side "LONG" "做多" "SHORT" "做空"}} +{{line "建议杠杆" .leverageText}}入场区域:{{.entryRange}} +{{line "风险控制(止损)" .stopLossPrice}}止盈目标: +{{with .tp1}}TP1:{{.}} +{{end}}{{with .tp2}}TP2:{{.}} +{{end}}{{with .tp3}}TP3:{{.}} +{{end}}{{with .tp4}}TP4:{{.}} +{{end}}{{with .tp5}}TP5:{{.}} +{{end}}推送时间:{{.pushedAt}}` + +const aiCryptoGainTmpl = `止盈时间:{{.pushedAt}} +预警币种:{{.symbol}} +执行操作:{{.closeAction}} +平仓点位:{{.price}} +预警收益:{{.revenueDisplay}} +{{line "预警周期" .holdPeriod}}` + +const aiCryptoCloseTmpl = `止损时间:{{.pushedAt}} +预警币种:{{.symbol}} +执行操作:触发止损 +平仓点位:{{.price}} +最终损益:{{.revenueDisplay}} +{{line "预警周期" .holdPeriod}}` + +func TestRenderAICryptoOpenTemplate(t *testing.T) { + body := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"APE","direction":"LONG", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"APE\",\"isSale\":false,\"isClose\":false,\"isGain\":false,\"gainTarget\":5,\"price\":0.1235,\"lossPrice\":0.1223,\"gainPrices\":\"0.1241,0.1247,0.1253,0.1259,0.1265\",\"leverage\":31}", + "eventTime":1786850413251 + }`) + _, data, err := Convert(body) + if err != nil { + t.Fatal(err) + } + out, err := engine.NewRenderer().Render(aiCryptoOpenTmpl, data) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "预警币种:APE", + "交易方向:做多", + "建议杠杆:31x", + "入场区域:0.1235", + "风险控制(止损):0.1223", + "TP1:0.1241", + "TP2:0.1247", + "TP3:0.1253", + "TP4:0.1259", + "TP5:0.1265", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } +} + +func TestRenderAICryptoGainAndCloseTemplates(t *testing.T) { + gainBody := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"LTC","direction":"SHORT", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"isGain\":true,\"gainTarget\":2,\"price\":0.1598,\"remark\":\"{\\\"revenue\\\":\\\"22.67%\\\",\\\"period\\\":\\\"1小时38分钟\\\"}\"}", + "eventTime":1786897763403 + }`) + _, data, err := Convert(gainBody) + if err != nil { + t.Fatal(err) + } + out, err := engine.NewRenderer().Render(aiCryptoGainTmpl, data) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "预警币种:LTC", + "执行操作:到达第二止盈 (TP2)", + "平仓点位:0.1598", + "预警收益:+22.67%", + "预警周期:1小时38分钟", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + + closeBody := []byte(`{ + "eventType":"SIGNAL_RECEIVED","symbol":"ETH","direction":"SHORT", + "payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"isGain\":false,\"price\":57.95,\"remark\":\"{\\\"revenue\\\":\\\"30.28%\\\",\\\"period\\\":\\\"2小时55分钟\\\"}\"}", + "eventTime":1786897035113 + }`) + _, data, err = Convert(closeBody) + if err != nil { + t.Fatal(err) + } + out, err = engine.NewRenderer().Render(aiCryptoCloseTmpl, data) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "预警币种:ETH", + "执行操作:触发止损", + "平仓点位:57.95", + "最终损益:-30.28%", + "预警周期:2小时55分钟", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } +}