fix(交易信号): 修复极小价格展示精度丢失与策略覆盖匹配失效

Motivation:
部分 meme 币等标的价格极小(如 0.00000059),原有格式化统一保留两位小数会将其显示为 0.00,导致推送消息中的价格信息失真、误导用户;同时 viper 加载配置时会将嵌套 map 的 key 统一转为小写,导致按大写策略编码配置的策略覆盖项无法命中,仓位倍数、杠杆等覆盖参数失效。

Changes:

* 新增价格展示格式化逻辑:绝对值不小于 1 的数值保留两位小数,小于 1 的数值采用最短精确表示,避免极小价格被截断为 0.00
* 策略覆盖查找改为大小写不敏感匹配,兼容 viper 将配置 key 小写化的行为,确保策略编码以任意大小写配置均可生效
* 补充极小价格展示、配置加载解析及策略覆盖匹配的单元测试
This commit is contained in:
2026-08-19 01:03:29 +08:00
parent 7651c57536
commit cd3db8d453
7 changed files with 161 additions and 21 deletions
+44
View File
@@ -92,6 +92,50 @@ func TestActiveSubscriptionsSkipsPlaceholderURL(t *testing.T) {
}
}
func TestLoadParsesStrategyOverrides(t *testing.T) {
cfg, err := Load(filepath.Join("..", "..", "config", "config.yaml"))
if err != nil {
t.Fatal(err)
}
var sub *SubscriptionConfig
for i := range cfg.Subscriptions {
if cfg.Subscriptions[i].Name == "trade-signal" {
sub = &cfg.Subscriptions[i]
break
}
}
if sub == nil {
t.Fatal("trade-signal subscription not found")
}
if len(sub.StrategyOverrides) == 0 {
t.Fatalf("strategy_overrides not parsed: %+v", *sub)
}
// Viper lower-cases nested map keys during load, so the parsed key is "blong".
o, ok := sub.StrategyOverrides["blong"]
if !ok {
t.Fatalf("blong override missing, got keys=%v", sub.StrategyOverrides)
}
got := o.QuantityMultiplierFor("OPEN")
if got != 100 {
t.Fatalf("open multiplier=%v want 100", got)
}
if o.Leverage == nil || *o.Leverage != 100 {
t.Fatalf("leverage=%v want 100", o.Leverage)
}
}
func TestLoadStrategyOverridesKeyInsensitive(t *testing.T) {
// Confirms viper lower-cases keys; tradesignal.overrideFor matches case-insensitively.
sub := SubscriptionConfig{StrategyOverrides: map[string]StrategyOverride{
"blong": {Leverage: intPtr(100)},
}}
if _, ok := sub.StrategyOverrides["BLONG"]; ok {
t.Fatalf("expected case-sensitive map; overrides=%v", sub.StrategyOverrides)
}
}
func intPtr(v int) *int { return &v }
func TestQuantityMultiplierFor(t *testing.T) {
o := StrategyOverride{QuantityMultipliers: QuantityMultipliers{Open: 100, Add: 0}}
if o.QuantityMultiplierFor("OPEN") != 100 {
+19 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -244,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("平仓价格: %.2f", p.Price))
lines = append(lines, fmt.Sprintf("平仓价格: %s", formatPrice(p.Price)))
}
case "GAIN":
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("止盈价格: %.2f", p.Price))
lines = append(lines, fmt.Sprintf("止盈价格: %s", formatPrice(p.Price)))
}
case "SELL":
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("卖出价格: %.2f", p.Price))
lines = append(lines, fmt.Sprintf("卖出价格: %s", formatPrice(p.Price)))
}
default:
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", p.Price))
lines = append(lines, fmt.Sprintf("开仓价格: %s", formatPrice(p.Price)))
}
}
if p.LossPrice > 0 {
lines = append(lines, fmt.Sprintf("止损价格: %.2f", p.LossPrice))
lines = append(lines, fmt.Sprintf("止损价格: %s", formatPrice(p.LossPrice)))
}
if er := entryRange(p.Price, p.OpenPrice2); er != "" && p.OpenPrice2 != 0 {
lines = append(lines, fmt.Sprintf("介入区间: %s", er))
@@ -359,6 +360,19 @@ 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
@@ -250,6 +250,30 @@ func asFloat(t *testing.T, v any) float64 {
}
}
func TestConvertTinyPriceKeepsPrecision(t *testing.T) {
body := []byte(`{
"eventType":"SIGNAL_RECEIVED","symbol":"PEPE","direction":"LONG",
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"period\":\"1h\",\"currency\":\"PEPE\",\"price\":0.00000059,\"lossPrice\":0.00000055,\"gainPrices\":\"0.00000061,0.00000064\"}",
"eventTime":1786600000000
}`)
_, data, err := Convert(body)
if err != nil {
t.Fatal(err)
}
formatted, _ := data["formatted"].(string)
for _, want := range []string{
"开仓价格: 0.00000059\n",
"止损价格: 0.00000055\n",
} {
if !strings.Contains(formatted, want) {
t.Fatalf("missing %q in\n%s", want, formatted)
}
}
if strings.Contains(formatted, "开仓价格: 0.00\n") {
t.Fatalf("tiny price rounded to 0.00:\n%s", formatted)
}
}
func TestConvertInvalidJSON(t *testing.T) {
_, _, err := Convert([]byte(`{not json`))
if err == nil {
+7 -4
View File
@@ -61,11 +61,14 @@ func (c *Converter) overrideFor(code string) *config.StrategyOverride {
if c == nil || len(c.overrides) == 0 || code == "" {
return nil
}
override, ok := c.overrides[code]
if !ok {
return nil
}
// viper lower-cases nested map keys, so match case-insensitively.
if override, ok := c.overrides[code]; ok {
return &override
}
if override, ok := c.overrides[strings.ToLower(code)]; ok {
return &override
}
return nil
}
func toData(body []byte, sig *Signal) (map[string]interface{}, error) {
@@ -36,6 +36,25 @@ func TestConvertOpen(t *testing.T) {
}
}
// TestOverrideForLowercaseKey verifies overrides still match when viper
// lower-cases the strategy_overrides map keys during config load.
func TestOverrideForLowercaseKey(t *testing.T) {
lev := 100
c := NewConverter(map[string]config.StrategyOverride{
"blong": {QuantityMultipliers: config.QuantityMultipliers{Add: 100}, Leverage: &lev},
})
o := c.overrideFor("BLONG")
if o == nil {
t.Fatal("overrideFor(BLONG)=nil, lowercase config key should match")
}
if got := o.QuantityMultiplierFor("ADD"); got != 100 {
t.Fatalf("add multiplier=%v want 100", got)
}
if o.Leverage == nil || *o.Leverage != 100 {
t.Fatalf("leverage=%v want 100", o.Leverage)
}
}
func TestConvertKeepsExtraJSONFields(t *testing.T) {
_, data, err := NewConverter(nil).Convert([]byte(`{
"action":"OPEN","symbol":"BTCUSDT","price":63014.61,
+27 -12
View File
@@ -2,6 +2,8 @@ package tradesignal
import (
"fmt"
"math"
"strconv"
"strings"
"aiaa-notification-service/internal/tz"
@@ -29,7 +31,7 @@ func Format(signal *Signal, opts ...FormatOptions) string {
action := strings.ToUpper(signal.Action)
switch action {
case "OPEN":
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", signal.Price))
lines = append(lines, fmt.Sprintf("开仓价格: %s", formatPrice(signal.Price)))
if line := sizeLine("OPEN", signal.Quantity, signal.AmountMarginRatio); line != "" {
lines = append(lines, line)
}
@@ -38,23 +40,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("止盈价格: %.2f", *signal.TakeProfitPrice))
lines = append(lines, fmt.Sprintf("止盈价格: %s", formatPrice(*signal.TakeProfitPrice)))
}
if signal.StopLossPrice != nil {
lines = append(lines, fmt.Sprintf("止损价格: %.2f", *signal.StopLossPrice))
lines = append(lines, fmt.Sprintf("止损价格: %s", formatPrice(*signal.StopLossPrice)))
}
case "CLOSE":
lines = append(lines, fmt.Sprintf("平仓价格: %.2f", signal.Price))
lines = append(lines, fmt.Sprintf("平仓价格: %s", 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("平仓盈亏: %.2f", *signal.PnL))
lines = append(lines, fmt.Sprintf("平仓盈亏: %s", formatPrice(*signal.PnL)))
}
if signal.AccountBalance != nil {
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
lines = append(lines, fmt.Sprintf("账户余额:%s", formatPrice(*signal.AccountBalance)))
}
case "ADD":
lines = append(lines, fmt.Sprintf("加仓价格: %.2f", signal.Price))
lines = append(lines, fmt.Sprintf("加仓价格: %s", formatPrice(signal.Price)))
if line := sizeLine("ADD", signal.Quantity, signal.AmountMarginRatio); line != "" {
lines = append(lines, line)
}
@@ -63,19 +65,19 @@ func Format(signal *Signal, opts ...FormatOptions) string {
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
}
case "REDUCE":
lines = append(lines, fmt.Sprintf("减仓价格: %.2f", signal.Price))
lines = append(lines, fmt.Sprintf("减仓价格: %s", 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("减仓盈亏: %.2f", *signal.PnL))
lines = append(lines, fmt.Sprintf("减仓盈亏: %s", formatPrice(*signal.PnL)))
}
if signal.AccountBalance != nil {
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
lines = append(lines, fmt.Sprintf("账户余额:%s", formatPrice(*signal.AccountBalance)))
}
default:
lines = append(lines, fmt.Sprintf("价格: %.2f", signal.Price))
lines = append(lines, fmt.Sprintf("价格: %s", formatPrice(signal.Price)))
if line := sizeLine("", signal.Quantity, signal.AmountMarginRatio); line != "" {
lines = append(lines, line)
}
@@ -127,7 +129,7 @@ func appendAvgPrice(lines []string, avgPrice *float64) []string {
if avgPrice == nil || *avgPrice <= 0 {
return lines
}
return append(lines, fmt.Sprintf("平均单价: %.2f", *avgPrice))
return append(lines, fmt.Sprintf("平均单价: %s", formatPrice(*avgPrice)))
}
func closeSizeLine(quantity, posMarginRatio *float64) string {
@@ -188,6 +190,19 @@ 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"} {
@@ -58,3 +58,24 @@ func TestFormatWithAvgPrice(t *testing.T) {
t.Fatalf("%s", out)
}
}
func TestFormatTinyPriceKeepsPrecision(t *testing.T) {
tp, sl := 0.00000061, 0.00000058
out := Format(&Signal{
Symbol: "PEPEUSDT", Side: "LONG", Action: "OPEN",
Price: 0.00000059, TakeProfitPrice: &tp, StopLossPrice: &sl,
EventTime: "2026-06-23T01:30:00Z",
})
for _, want := range []string{
"开仓价格: 0.00000059\n",
"止盈价格: 0.00000061\n",
"止损价格: 0.00000058\n",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in\n%s", want, out)
}
}
if strings.Contains(out, "开仓价格: 0.00\n") {
t.Fatalf("tiny price rounded to 0.00:\n%s", out)
}
}