Files
aiaa-notification-server/internal/subscriber/cryptostrategy/convert.go
T
ryan cd3db8d453 fix(交易信号): 修复极小价格展示精度丢失与策略覆盖匹配失效
Motivation:
部分 meme 币等标的价格极小(如 0.00000059),原有格式化统一保留两位小数会将其显示为 0.00,导致推送消息中的价格信息失真、误导用户;同时 viper 加载配置时会将嵌套 map 的 key 统一转为小写,导致按大写策略编码配置的策略覆盖项无法命中,仓位倍数、杠杆等覆盖参数失效。

Changes:

* 新增价格展示格式化逻辑:绝对值不小于 1 的数值保留两位小数,小于 1 的数值采用最短精确表示,避免极小价格被截断为 0.00
* 策略覆盖查找改为大小写不敏感匹配,兼容 viper 将配置 key 小写化的行为,确保策略编码以任意大小写配置均可生效
* 补充极小价格展示、配置加载解析及策略覆盖匹配的单元测试
2026-08-19 01:03:29 +08:00

446 lines
11 KiB
Go

package cryptostrategy
import (
"bytes"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"aiaa-notification-service/internal/tz"
)
type envelope struct {
EventType string `json:"eventType"`
CorrelationID string `json:"correlationId"`
Symbol string `json:"symbol"`
Direction string `json:"direction"`
Payload json.RawMessage `json:"payload"`
EventTime int64 `json:"eventTime"`
}
type payload struct {
StrategyCode string `json:"strategyCode"`
Period string `json:"period"`
Currency string `json:"currency"`
IsSale bool `json:"isSale"`
IsClose bool `json:"isClose"`
IsGain bool `json:"isGain"`
GainTarget float64 `json:"gainTarget"`
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"`
}
type remark struct {
OrderID string `json:"orderId"`
Revenue string `json:"revenue"`
Period string `json:"period"`
}
type Converter struct{}
func NewConverter() *Converter { return &Converter{} }
func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error) {
return Convert(body)
}
func Convert(body []byte) (string, map[string]interface{}, error) {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return "", nil, fmt.Errorf("invalid envelope: %w", err)
}
p, payloadJSON, err := parsePayload(body, env.Payload)
if err != nil {
return "", nil, err
}
action := inferAction(p)
event := eventName(p.StrategyCode, action)
text := format(env, p, action)
side := strings.ToUpper(strings.TrimSpace(env.Direction))
if side == "" {
if p.IsSale {
side = "SHORT"
} else {
side = "LONG"
}
}
data := map[string]interface{}{
"eventType": env.EventType,
"correlationId": env.CorrelationID,
"symbol": firstNonEmpty(env.Symbol, p.Currency),
"direction": firstNonEmpty(env.Direction, side),
"side": side,
"action": action,
"eventTime": env.EventTime,
"strategyCode": p.StrategyCode,
"period": p.Period,
"currency": p.Currency,
"isSale": p.IsSale,
"isClose": p.IsClose,
"isGain": p.IsGain,
"gainTarget": p.GainTarget,
"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"] = tz.Format(time.UnixMilli(env.EventTime), "2006-01-02 15:04:05")
}
if p.TotalGainTarget != 0 {
data["totalGainTarget"] = p.TotalGainTarget
}
if p.Leverage > 0 {
data["leverageText"] = fmt.Sprintf("%dx", p.Leverage)
}
for i, price := range splitPrices(p.GainPrices) {
if i >= 5 {
break
}
data[fmt.Sprintf("tp%d", i+1)] = compactPrice(price)
}
if p.IsGain {
data["closeAction"] = formatTPAction(p.GainTarget)
}
if r := parseRemark(p.Remark); r.OrderID != "" || r.Revenue != "" || r.Period != "" {
if r.OrderID != "" {
data["orderId"] = r.OrderID
}
if r.Revenue != "" {
data["revenue"] = r.Revenue
data["revenueDisplay"] = formatRevenue(r.Revenue, p.IsGain)
}
if hp := formatHoldPeriod(r.Period); hp != "" {
data["holdPeriod"] = hp
}
}
return event, data, nil
}
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, nil, fmt.Errorf("invalid payload: %w", err)
}
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, nil, fmt.Errorf("invalid payload: %w", err)
}
return p, body, nil
}
raw = []byte(asString)
}
if err := json.Unmarshal(raw, &p); err != nil {
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
}
}
func eventName(strategyCode, action string) string {
code := strings.ToUpper(strings.TrimSpace(strategyCode))
suffix := strings.ToLower(action)
switch code {
case "HLSS", "AMA", "BTS", "AGTS":
return code + "." + suffix
default:
return "trade." + suffix
}
}
func inferAction(p payload) string {
if strings.EqualFold(strings.TrimSpace(p.StrategyCode), "HLSS") {
switch {
case p.IsClose:
return "CLOSE"
case p.IsGain:
return "GAIN"
case p.IsSale:
return "SELL"
default:
return "OPEN"
}
}
switch {
case p.IsGain:
return "GAIN"
case p.IsClose:
return "CLOSE"
default:
return "OPEN"
}
}
func takeProfitPrice(p payload) interface{} {
if gp := strings.TrimSpace(p.GainPrices); gp != "" {
return gp
}
if p.IsGain && p.Price > 0 {
return p.Price
}
return ""
}
func parseRemark(raw string) remark {
raw = strings.TrimSpace(raw)
if raw == "" {
return remark{}
}
var r remark
if err := json.Unmarshal([]byte(raw), &r); err != nil {
return remark{}
}
return r
}
func format(env envelope, p payload, action string) string {
symbol := firstNonEmpty(env.Symbol, p.Currency)
lines := []string{actionTitle(env.Direction, action)}
if symbol != "" {
lines = append(lines, fmt.Sprintf("交易品种: %s", symbol))
}
if p.Period != "" {
lines = append(lines, fmt.Sprintf("周期: %s", p.Period))
}
switch action {
case "CLOSE":
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("平仓价格: %s", formatPrice(p.Price)))
}
case "GAIN":
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("止盈价格: %s", formatPrice(p.Price)))
}
case "SELL":
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("卖出价格: %s", formatPrice(p.Price)))
}
default:
if p.Price > 0 {
lines = append(lines, fmt.Sprintf("开仓价格: %s", formatPrice(p.Price)))
}
}
if p.LossPrice > 0 {
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))
}
if gp := strings.TrimSpace(p.GainPrices); gp != "" && action != "GAIN" {
lines = append(lines, fmt.Sprintf("止盈价格: %s", strings.Join(splitPrices(gp), ", ")))
}
if p.GainTarget != 0 {
lines = append(lines, fmt.Sprintf("止盈目标: %g", p.GainTarget))
}
if p.Leverage > 0 {
lines = append(lines, fmt.Sprintf("杠杆: %dx", p.Leverage))
}
if p.StrategyCode != "" {
lines = append(lines, fmt.Sprintf("策略: %s", p.StrategyCode))
}
if env.EventTime > 0 {
t := time.UnixMilli(env.EventTime).In(tz.CST)
lines = append(lines, fmt.Sprintf("Time: %s", t.Format("2006.01.02 15:04:05")))
}
return strings.Join(lines, "\n")
}
func actionTitle(direction, action string) string {
var pos string
switch strings.ToUpper(direction) {
case "LONG":
pos = "多单"
case "SHORT":
pos = "空单"
default:
pos = direction
}
var act string
switch action {
case "OPEN":
act = "开仓"
case "CLOSE":
act = "平仓"
case "GAIN":
act = "止盈"
case "SELL":
act = "卖出"
default:
act = action
}
return pos + act
}
func splitPrices(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
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)
}
// 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
}
return b
}
func formatTPAction(gainTarget float64) string {
n := int(gainTarget)
names := []string{"", "第一", "第二", "第三", "第四", "第五"}
if n >= 1 && n < len(names) {
return fmt.Sprintf("到达%s止盈 (TP%d)", names[n], n)
}
return "到达止盈"
}
func formatRevenue(raw string, isGain bool) string {
s := strings.ReplaceAll(strings.TrimSpace(raw), "%", "")
s = strings.TrimSpace(s)
if s == "" {
return ""
}
switch {
case strings.HasPrefix(s, "+"), strings.HasPrefix(s, "-"):
return s + "%"
case isGain:
return "+" + s + "%"
default:
return "-" + s + "%"
}
}
func formatHoldPeriod(raw string) string {
s := strings.TrimSpace(raw)
if s == "" || strings.EqualFold(s, "signal") {
return ""
}
if strings.Contains(s, "小时") || strings.Contains(s, "分钟") {
return s
}
lower := strings.ToLower(s)
if n, ok := parseTrailingNumber(strings.TrimSpace(strings.TrimSuffix(lower, "min"))); ok {
return formatMinutes(n)
}
return s
}
func parseTrailingNumber(s string) (int, bool) {
s = strings.TrimSpace(s)
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}
func formatMinutes(n int) string {
if n <= 0 {
return ""
}
h, m := n/60, n%60
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%d小时%d分钟", h, m)
case h > 0:
return fmt.Sprintf("%d小时", h)
default:
return fmt.Sprintf("%d分钟", n)
}
}