diff --git a/internal/subscriber/cryptostrategy/convert.go b/internal/subscriber/cryptostrategy/convert.go index c5f3a16..b2de3a8 100644 --- a/internal/subscriber/cryptostrategy/convert.go +++ b/internal/subscriber/cryptostrategy/convert.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strconv" "strings" "time" ) @@ -28,6 +29,7 @@ type payload struct { 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"` @@ -51,13 +53,16 @@ func Convert(body []byte) (string, map[string]interface{}, error) { if err := json.Unmarshal(body, &env); err != nil { return "", nil, fmt.Errorf("invalid envelope: %w", err) } - p, err := parsePayload(body, env.Payload) + p, payloadJSON, err := parsePayload(body, env.Payload) if err != nil { return "", nil, err } action := inferAction(p) event := "trade." + strings.ToLower(action) + if strings.EqualFold(strings.TrimSpace(p.StrategyCode), "HLSS") { + event = "HLSS." + strings.ToLower(action) + } text := format(env, p, action) data := map[string]interface{}{ @@ -78,12 +83,19 @@ func Convert(body []byte) (string, map[string]interface{}, error) { "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"] = time.UnixMilli(env.EventTime).In(time.Local).Format("2006-01-02 15:04:05") + } if p.TotalGainTarget != 0 { data["totalGainTarget"] = p.TotalGainTarget } @@ -98,30 +110,46 @@ func Convert(body []byte) (string, map[string]interface{}, error) { return event, data, nil } -func parsePayload(body []byte, raw json.RawMessage) (payload, error) { +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, fmt.Errorf("invalid payload: %w", err) + return p, nil, fmt.Errorf("invalid payload: %w", err) } - return p, nil + 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, fmt.Errorf("invalid payload: %w", err) + return p, nil, fmt.Errorf("invalid payload: %w", err) } - return p, nil + return p, body, nil } raw = []byte(asString) } if err := json.Unmarshal(raw, &p); err != nil { - return p, fmt.Errorf("invalid payload: %w", err) + 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 } - return p, nil } func inferAction(p payload) string { @@ -189,6 +217,9 @@ func format(env envelope, p payload, action string) string { if p.LossPrice > 0 { lines = append(lines, fmt.Sprintf("止损价格: %.2f", 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), ", "))) } @@ -246,6 +277,43 @@ func splitPrices(s string) []string { 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 diff --git a/internal/subscriber/cryptostrategy/convert_test.go b/internal/subscriber/cryptostrategy/convert_test.go index dc4f560..d8094cb 100644 --- a/internal/subscriber/cryptostrategy/convert_test.go +++ b/internal/subscriber/cryptostrategy/convert_test.go @@ -157,6 +157,80 @@ func TestConvertRendersSharedSignalTemplate(t *testing.T) { } } +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 {