From 6f5aaa9f857fa32f4099d505ce21bd2a1ed7fb6f Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 23 Aug 2026 00:12:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E4=BB=B7=E6=A0=BC=E5=B1=95=E7=A4=BA):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A8=A1=E6=9D=BF=E6=B8=B2=E6=9F=93=E6=97=B6?= =?UTF-8?q?=E6=9E=81=E5=B0=8F=E4=BB=B7=E6=A0=BC=E8=A2=AB=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E4=B8=BA=200.00=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: 此前的修复仅覆盖直接拼接文案的场景,跟单等基于模板渲染的推送场景中,%.2f 格式化仍会把 PEPE 等 Meme 币的极小价格(如 0.00000059)折叠为 0.00,导致交易通知丢失真实价格、误导用户。 Changes: * 抽取共享的价格显示逻辑到统一显示层,两个订阅者改为复用,移除重复实现 * 新增 Decimal 类型,渲染时以副本方式包装模板数据,使模板内 printf 风格格式化保留极小价格精度且不出现科学计数法,同时不修改调用方原始数据 * 渲染引擎空值判断改用反射实现,覆盖全部整型、无符号整型与浮点类型 * 补充转换到渲染的端到端回归测试,覆盖极小价格精度、科学计数法与数据不可变性 --- internal/engine/renderer.go | 27 +++++----- internal/engine/renderer_test.go | 50 ++++++++++++++++++- internal/subscriber/cryptostrategy/convert.go | 25 +++------- .../subscriber/cryptostrategy/convert_test.go | 12 +++++ .../subscriber/tradesignal/convert_test.go | 41 +++++++++++++++ internal/subscriber/tradesignal/format.go | 40 +++++---------- 6 files changed, 135 insertions(+), 60 deletions(-) diff --git a/internal/engine/renderer.go b/internal/engine/renderer.go index 1217ebd..504b496 100644 --- a/internal/engine/renderer.go +++ b/internal/engine/renderer.go @@ -3,8 +3,11 @@ package engine import ( "bytes" "fmt" + "reflect" "strings" "text/template" + + "aiaa-notification-service/internal/display" ) type Renderer struct{} @@ -26,7 +29,7 @@ func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (stri return "", fmt.Errorf("parse template: %w", err) } var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { + if err := tmpl.Execute(&buf, display.WrapMap(data)); err != nil { return "", fmt.Errorf("execute template: %w", err) } return buf.String(), nil @@ -82,17 +85,17 @@ func isEmptyValue(v any) bool { return strings.TrimSpace(t) == "" case bool: return !t - case int: - return t == 0 - case int32: - return t == 0 - case int64: - return t == 0 - case float32: - return t == 0 - case float64: - return t == 0 default: - return false + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return rv.Uint() == 0 + case reflect.Float32, reflect.Float64: + return rv.Float() == 0 + default: + return false + } } } diff --git a/internal/engine/renderer_test.go b/internal/engine/renderer_test.go index aaab2c7..b35d05d 100644 --- a/internal/engine/renderer_test.go +++ b/internal/engine/renderer_test.go @@ -33,7 +33,7 @@ func TestRendererLineOmitsEmpty(t *testing.T) { "symbol": "ICP", "price": 2.273, "totalAvgPx": "", - "stopLossPrice": 0, + "stopLossPrice": float64(0), }) if err != nil { t.Fatal(err) @@ -122,8 +122,12 @@ func TestRendererCaseDefault(t *testing.T) { } } +// copyTradeTmpl is the production 跟单策略 template. It formats prices with +// printf "%.2f", which must not collapse meme-coin prices like PEPE to 0.00. +const copyTradeTmpl = "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}" + func TestCopyTradeTemplate(t *testing.T) { - tmpl := "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}" + tmpl := copyTradeTmpl r := NewRenderer() cases := []struct { name string @@ -169,6 +173,21 @@ func TestCopyTradeTemplate(t *testing.T) { }, want: []string{"空单加仓", "加仓价格: 1933.05", "加仓数量: 3.80", "杠杆: 100x", "策略: B龙策略", "推送时间: 2026.08.10 06:14:28"}, }, + { + name: "pepe-open-keeps-tiny-price", + data: map[string]interface{}{ + "side": "SHORT", "action": "OPEN", "symbol": "PEPEUSDT", + "price": 0.00000059, "quantity": 1000000000.0, "avgPrice": 0.00000059, + "leverage": 100, "strategyCode": "BLONG", "pushedAt": "2026.08.22 10:04:29", + }, + want: []string{ + "空单开仓", "交易品种: PEPEUSDT", + "开仓价格: 0.00000059", "开仓数量: 1000000000.00", + "平均单价: 0.00000059", "杠杆: 100x", + "策略: B龙策略", "推送时间: 2026.08.22 10:04:29", + }, + not: []string{"开仓价格: 0.00\n", "平均单价: 0.00\n"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -190,3 +209,30 @@ func TestCopyTradeTemplate(t *testing.T) { }) } } + +func TestRendererTinyPriceNotScientificOrRounded(t *testing.T) { + r := NewRenderer() + data := map[string]interface{}{"price": 0.00000059, "stopLossPrice": 0.00000055} + out, err := r.Render(`价格: {{.price}} +{{line "止损价" .stopLossPrice}}`, data) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "e-") || strings.Contains(out, "E-") { + t.Fatalf("tiny price lost precision:\n%s", out) + } + if !strings.Contains(out, "价格: 0.00000059") || !strings.Contains(out, "止损价:0.00000055") { + t.Fatalf("out=%q", out) + } +} + +func TestRendererDoesNotMutateData(t *testing.T) { + r := NewRenderer() + data := map[string]interface{}{"price": 0.00000059} + if _, err := r.Render(`{{printf "%.2f" .price}}`, data); err != nil { + t.Fatal(err) + } + if _, ok := data["price"].(float64); !ok { + t.Fatalf("render mutated caller data: %T", data["price"]) + } +} diff --git a/internal/subscriber/cryptostrategy/convert.go b/internal/subscriber/cryptostrategy/convert.go index d299bec..f97caee 100644 --- a/internal/subscriber/cryptostrategy/convert.go +++ b/internal/subscriber/cryptostrategy/convert.go @@ -4,11 +4,11 @@ import ( "bytes" "encoding/json" "fmt" - "math" "strconv" "strings" "time" + "aiaa-notification-service/internal/display" "aiaa-notification-service/internal/tz" ) @@ -245,23 +245,23 @@ func format(env envelope, p payload, action string) string { switch action { case "CLOSE": if p.Price > 0 { - lines = append(lines, fmt.Sprintf("平仓价格: %s", formatPrice(p.Price))) + lines = append(lines, fmt.Sprintf("平仓价格: %s", display.FormatPrice(p.Price))) } case "GAIN": if p.Price > 0 { - lines = append(lines, fmt.Sprintf("止盈价格: %s", formatPrice(p.Price))) + lines = append(lines, fmt.Sprintf("止盈价格: %s", display.FormatPrice(p.Price))) } case "SELL": if p.Price > 0 { - lines = append(lines, fmt.Sprintf("卖出价格: %s", formatPrice(p.Price))) + lines = append(lines, fmt.Sprintf("卖出价格: %s", display.FormatPrice(p.Price))) } default: if p.Price > 0 { - lines = append(lines, fmt.Sprintf("开仓价格: %s", formatPrice(p.Price))) + lines = append(lines, fmt.Sprintf("开仓价格: %s", display.FormatPrice(p.Price))) } } if p.LossPrice > 0 { - lines = append(lines, fmt.Sprintf("止损价格: %s", formatPrice(p.LossPrice))) + lines = append(lines, fmt.Sprintf("止损价格: %s", display.FormatPrice(p.LossPrice))) } if er := entryRange(p.Price, p.OpenPrice2); er != "" && p.OpenPrice2 != 0 { lines = append(lines, fmt.Sprintf("介入区间: %s", er)) @@ -360,19 +360,6 @@ func formatFloat(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } -// formatPrice renders a price for display. Values >= 1 keep two decimals for -// readability; smaller values use the shortest exact representation so tiny -// prices like 0.00000059 are not collapsed to 0.00. -func formatPrice(v float64) string { - if v == 0 { - return "0.00" - } - if math.Abs(v) >= 1 { - return strconv.FormatFloat(v, 'f', 2, 64) - } - return strconv.FormatFloat(v, '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 fa1e7b0..a658bfd 100644 --- a/internal/subscriber/cryptostrategy/convert_test.go +++ b/internal/subscriber/cryptostrategy/convert_test.go @@ -272,6 +272,18 @@ func TestConvertTinyPriceKeepsPrecision(t *testing.T) { if strings.Contains(formatted, "开仓价格: 0.00\n") { t.Fatalf("tiny price rounded to 0.00:\n%s", formatted) } + out, err := engine.NewRenderer().Render("价格:{{.price}}\n止损:{{.stopLossPrice}}\n{{line \"止盈\" .takeProfitPrice}}", data) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"价格:0.00000059", "止损:0.00000055"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in rendered\n%s", want, out) + } + } + if strings.Contains(out, "e-") || strings.Contains(out, "E-") { + t.Fatalf("scientific notation in rendered\n%s", out) + } } func TestConvertInvalidJSON(t *testing.T) { diff --git a/internal/subscriber/tradesignal/convert_test.go b/internal/subscriber/tradesignal/convert_test.go index 27b645a..b2f7199 100644 --- a/internal/subscriber/tradesignal/convert_test.go +++ b/internal/subscriber/tradesignal/convert_test.go @@ -6,6 +6,7 @@ import ( "testing" "aiaa-notification-service/internal/config" + "aiaa-notification-service/internal/engine" ) func TestConvertOpen(t *testing.T) { @@ -97,3 +98,43 @@ func TestConvertRawMessageWithoutAction(t *testing.T) { t.Fatalf("data=%v", data) } } + +func TestConvertThenRenderCopyTradeTinyPEPE(t *testing.T) { + lev := 100 + c := NewConverter(map[string]config.StrategyOverride{ + "BLONG": {QuantityMultipliers: config.QuantityMultipliers{Open: 100}, Leverage: &lev}, + }) + _, data, err := c.Convert([]byte(`{ + "signalId":"s1","strategyCode":"BLONG","symbol":"PEPEUSDT", + "side":"SHORT","action":"OPEN","quantity":10000000,"price":0.00000059, + "leverage":10,"eventTime":"2026-08-22T02:04:29Z" + }`)) + if err != nil { + t.Fatal(err) + } + if p, ok := data["price"].(float64); !ok || p != 0.00000059 { + t.Fatalf("convert price=%v (%T), want float64 0.00000059", data["price"], data["price"]) + } + data["pushedAt"] = "2026.08.22 10:04:29" + + const tmpl = "{{case .side \"LONG\" \"多单\" \"SHORT\" \"空单\"}}{{case .action \"OPEN\" \"开仓\" \"ADD\" \"加仓\" \"CLOSE\" \"平仓\" \"REDUCE\" \"减仓\"}}\n交易品种: {{case .symbol \"ETHUSDT\" \"ETH\" \"BTCUSDT\" \"BTC\" \"SOLUSDT\" \"SOL\" \"BNBUSDT\" \"BNB\" .symbol}}\n{{case .action \"OPEN\" \"开仓价格\" \"ADD\" \"加仓价格\" \"CLOSE\" \"平仓价格\" \"REDUCE\" \"减仓价格\"}}: {{printf \"%.2f\" .price}}\n{{case .action \"OPEN\" \"开仓数量\" \"ADD\" \"加仓数量\" \"CLOSE\" \"平仓数量\" \"REDUCE\" \"减仓数量\"}}: {{printf \"%.2f\" .quantity}}\n平均单价: {{printf \"%.2f\" .avgPrice}}\n{{if or (eq .action \"OPEN\") (eq .action \"ADD\")}}{{if .leverage}}杠杆: {{.leverage}}x\n{{end}}{{end}}策略: {{case .strategyCode \"BLONG\" \"B龙策略\" .strategyCode}}\n推送时间: {{.pushedAt}}" + out, err := engine.NewRenderer().Render(tmpl, data) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "空单开仓", + "交易品种: PEPEUSDT", + "开仓价格: 0.00000059", + "开仓数量: 1000000000.00", + "平均单价: 0.00000059", + "杠杆: 100x", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "开仓价格: 0.00\n") || strings.Contains(out, "平均单价: 0.00\n") { + t.Errorf("tiny price collapsed to 0.00:\n%s", out) + } +} diff --git a/internal/subscriber/tradesignal/format.go b/internal/subscriber/tradesignal/format.go index 71fe8dc..0c51836 100644 --- a/internal/subscriber/tradesignal/format.go +++ b/internal/subscriber/tradesignal/format.go @@ -2,10 +2,9 @@ package tradesignal import ( "fmt" - "math" - "strconv" "strings" + "aiaa-notification-service/internal/display" "aiaa-notification-service/internal/tz" ) @@ -31,7 +30,7 @@ func Format(signal *Signal, opts ...FormatOptions) string { action := strings.ToUpper(signal.Action) switch action { case "OPEN": - lines = append(lines, fmt.Sprintf("开仓价格: %s", formatPrice(signal.Price))) + lines = append(lines, fmt.Sprintf("开仓价格: %s", display.FormatPrice(signal.Price))) if line := sizeLine("OPEN", signal.Quantity, signal.AmountMarginRatio); line != "" { lines = append(lines, line) } @@ -40,23 +39,23 @@ func Format(signal *Signal, opts ...FormatOptions) string { lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage)) } if signal.TakeProfitPrice != nil { - lines = append(lines, fmt.Sprintf("止盈价格: %s", formatPrice(*signal.TakeProfitPrice))) + lines = append(lines, fmt.Sprintf("止盈价格: %s", display.FormatPrice(*signal.TakeProfitPrice))) } if signal.StopLossPrice != nil { - lines = append(lines, fmt.Sprintf("止损价格: %s", formatPrice(*signal.StopLossPrice))) + lines = append(lines, fmt.Sprintf("止损价格: %s", display.FormatPrice(*signal.StopLossPrice))) } case "CLOSE": - lines = append(lines, fmt.Sprintf("平仓价格: %s", formatPrice(signal.Price))) + lines = append(lines, fmt.Sprintf("平仓价格: %s", display.FormatPrice(signal.Price))) lines = append(lines, closeSizeLine(signal.Quantity, signal.PosMarginRatio)) lines = appendAvgPrice(lines, opt.AvgPrice) if signal.PnL != nil { - lines = append(lines, fmt.Sprintf("平仓盈亏: %s", formatPrice(*signal.PnL))) + lines = append(lines, fmt.Sprintf("平仓盈亏: %s", display.FormatPrice(*signal.PnL))) } if signal.AccountBalance != nil { - lines = append(lines, fmt.Sprintf("账户余额:%s", formatPrice(*signal.AccountBalance))) + lines = append(lines, fmt.Sprintf("账户余额:%s", display.FormatPrice(*signal.AccountBalance))) } case "ADD": - lines = append(lines, fmt.Sprintf("加仓价格: %s", formatPrice(signal.Price))) + lines = append(lines, fmt.Sprintf("加仓价格: %s", display.FormatPrice(signal.Price))) if line := sizeLine("ADD", signal.Quantity, signal.AmountMarginRatio); line != "" { lines = append(lines, line) } @@ -65,19 +64,19 @@ func Format(signal *Signal, opts ...FormatOptions) string { lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage)) } case "REDUCE": - lines = append(lines, fmt.Sprintf("减仓价格: %s", formatPrice(signal.Price))) + lines = append(lines, fmt.Sprintf("减仓价格: %s", display.FormatPrice(signal.Price))) if line := sizeLine("REDUCE", signal.Quantity, signal.PosMarginRatio); line != "" { lines = append(lines, line) } lines = appendAvgPrice(lines, opt.AvgPrice) if signal.PnL != nil { - lines = append(lines, fmt.Sprintf("减仓盈亏: %s", formatPrice(*signal.PnL))) + lines = append(lines, fmt.Sprintf("减仓盈亏: %s", display.FormatPrice(*signal.PnL))) } if signal.AccountBalance != nil { - lines = append(lines, fmt.Sprintf("账户余额:%s", formatPrice(*signal.AccountBalance))) + lines = append(lines, fmt.Sprintf("账户余额:%s", display.FormatPrice(*signal.AccountBalance))) } default: - lines = append(lines, fmt.Sprintf("价格: %s", formatPrice(signal.Price))) + lines = append(lines, fmt.Sprintf("价格: %s", display.FormatPrice(signal.Price))) if line := sizeLine("", signal.Quantity, signal.AmountMarginRatio); line != "" { lines = append(lines, line) } @@ -129,7 +128,7 @@ func appendAvgPrice(lines []string, avgPrice *float64) []string { if avgPrice == nil || *avgPrice <= 0 { return lines } - return append(lines, fmt.Sprintf("平均单价: %s", formatPrice(*avgPrice))) + return append(lines, fmt.Sprintf("平均单价: %s", display.FormatPrice(*avgPrice))) } func closeSizeLine(quantity, posMarginRatio *float64) string { @@ -190,19 +189,6 @@ func formatPercent(ratio float64) string { return fmt.Sprintf("%.2f%%", ratio*100) } -// formatPrice renders a price for display. Values >= 1 keep two decimals for -// readability; smaller values use the shortest exact representation so tiny -// prices like 0.00000059 are not collapsed to 0.00. -func formatPrice(v float64) string { - if v == 0 { - return "0.00" - } - if math.Abs(v) >= 1 { - return strconv.FormatFloat(v, 'f', 2, 64) - } - return strconv.FormatFloat(v, 'f', -1, 64) -} - func trimQuote(symbol string) string { symbol = strings.ToUpper(symbol) for _, suffix := range []string{"USDT", "USDC", "BUSD", "USD"} {