diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 75bd0f0..b948396 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 { diff --git a/internal/subscriber/cryptostrategy/convert.go b/internal/subscriber/cryptostrategy/convert.go index 1980f9a..d299bec 100644 --- a/internal/subscriber/cryptostrategy/convert.go +++ b/internal/subscriber/cryptostrategy/convert.go @@ -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 diff --git a/internal/subscriber/cryptostrategy/convert_test.go b/internal/subscriber/cryptostrategy/convert_test.go index e2748c6..fa1e7b0 100644 --- a/internal/subscriber/cryptostrategy/convert_test.go +++ b/internal/subscriber/cryptostrategy/convert_test.go @@ -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 { diff --git a/internal/subscriber/tradesignal/convert.go b/internal/subscriber/tradesignal/convert.go index 946833a..6e169c0 100644 --- a/internal/subscriber/tradesignal/convert.go +++ b/internal/subscriber/tradesignal/convert.go @@ -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 } - 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) { diff --git a/internal/subscriber/tradesignal/convert_test.go b/internal/subscriber/tradesignal/convert_test.go index 6ebce9a..27b645a 100644 --- a/internal/subscriber/tradesignal/convert_test.go +++ b/internal/subscriber/tradesignal/convert_test.go @@ -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, diff --git a/internal/subscriber/tradesignal/format.go b/internal/subscriber/tradesignal/format.go index b374fd2..71fe8dc 100644 --- a/internal/subscriber/tradesignal/format.go +++ b/internal/subscriber/tradesignal/format.go @@ -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"} { diff --git a/internal/subscriber/tradesignal/format_test.go b/internal/subscriber/tradesignal/format_test.go index 3714d3a..8ff370d 100644 --- a/internal/subscriber/tradesignal/format_test.go +++ b/internal/subscriber/tradesignal/format_test.go @@ -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) + } +}