6f5aaa9f85
Motivation: 此前的修复仅覆盖直接拼接文案的场景,跟单等基于模板渲染的推送场景中,%.2f 格式化仍会把 PEPE 等 Meme 币的极小价格(如 0.00000059)折叠为 0.00,导致交易通知丢失真实价格、误导用户。 Changes: * 抽取共享的价格显示逻辑到统一显示层,两个订阅者改为复用,移除重复实现 * 新增 Decimal 类型,渲染时以副本方式包装模板数据,使模板内 printf 风格格式化保留极小价格精度且不出现科学计数法,同时不修改调用方原始数据 * 渲染引擎空值判断改用反射实现,覆盖全部整型、无符号整型与浮点类型 * 补充转换到渲染的端到端回归测试,覆盖极小价格精度、科学计数法与数据不可变性
433 lines
10 KiB
Go
433 lines
10 KiB
Go
package cryptostrategy
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"aiaa-notification-service/internal/display"
|
|
"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", display.FormatPrice(p.Price)))
|
|
}
|
|
case "GAIN":
|
|
if p.Price > 0 {
|
|
lines = append(lines, fmt.Sprintf("止盈价格: %s", display.FormatPrice(p.Price)))
|
|
}
|
|
case "SELL":
|
|
if p.Price > 0 {
|
|
lines = append(lines, fmt.Sprintf("卖出价格: %s", display.FormatPrice(p.Price)))
|
|
}
|
|
default:
|
|
if p.Price > 0 {
|
|
lines = append(lines, fmt.Sprintf("开仓价格: %s", display.FormatPrice(p.Price)))
|
|
}
|
|
}
|
|
if p.LossPrice > 0 {
|
|
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))
|
|
}
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|