feat(订阅): 支持 HLSS 策略止盈与介入区间格式化

Motivation:
HLSS 策略信号携带的止盈价位列表与介入价位含冗余小数位,直接推送可读性差,需转换为紧凑区间并补齐推送时间,提升通知的信息密度与可读性。

Changes:

* 新增止盈目标区间,将止盈价位列表去零后连接为区间
* 新增介入区间,由开仓价与介入价组合生成
* 压缩价格数值多余小数位,保留原始精度
* 注入推送时间,便于模板渲染提醒时间
This commit is contained in:
2026-08-16 23:21:59 +08:00
parent 14504af950
commit b3c42af53b
2 changed files with 150 additions and 8 deletions
+76 -8
View File
@@ -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
@@ -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 {