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 的模板与规则文档及单元测试
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user