feat(订阅): 新增 crypto-strategy 信号格式化器
Motivation: crypto-strategy 交易信号采用 envelope 外壳与字符串化/嵌套的 payload,原有 trade_signal 格式化器无法解析,需要独立的转换逻辑以生成通知文本和事件数据。 Changes: * 新增 crypto_strategy 消息转换器,解析 envelope 及嵌套、字符串化、扁平三种 payload 形态 * 根据平仓/止盈/卖出等标志推断交易动作,生成对应事件类型与中文通知文本 * 提取订单号、策略代码、价格、杠杆等字段用于通知数据 * 将消息转换器抽象为接口,订阅器按 formatter 配置选择对应实现 * 更新配置校验以支持 crypto_strategy 格式化器,并补充单元测试
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package cryptostrategy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"`
|
||||
Remark string `json:"remark"`
|
||||
TotalGainTarget float64 `json:"totalGainTarget"`
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
|
||||
type remark struct {
|
||||
OrderID string `json:"orderId"`
|
||||
}
|
||||
|
||||
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, err := parsePayload(body, env.Payload)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
action := inferAction(p)
|
||||
event := "trade." + strings.ToLower(action)
|
||||
text := format(env, p, action)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"eventType": env.EventType,
|
||||
"correlationId": env.CorrelationID,
|
||||
"symbol": firstNonEmpty(env.Symbol, p.Currency),
|
||||
"direction": env.Direction,
|
||||
"side": strings.ToUpper(env.Direction),
|
||||
"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,
|
||||
"leverage": p.Leverage,
|
||||
"formatted": text,
|
||||
}
|
||||
if p.TotalGainTarget != 0 {
|
||||
data["totalGainTarget"] = p.TotalGainTarget
|
||||
}
|
||||
if oid := parseOrderID(p.Remark); oid != "" {
|
||||
data["orderId"] = oid
|
||||
}
|
||||
return event, data, nil
|
||||
}
|
||||
|
||||
func parsePayload(body []byte, raw json.RawMessage) (payload, 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, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, 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, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
raw = []byte(asString)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return p, fmt.Errorf("invalid payload: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func inferAction(p payload) string {
|
||||
switch {
|
||||
case p.IsClose:
|
||||
return "CLOSE"
|
||||
case p.IsGain:
|
||||
return "GAIN"
|
||||
case p.IsSale:
|
||||
return "SELL"
|
||||
default:
|
||||
return "OPEN"
|
||||
}
|
||||
}
|
||||
|
||||
func parseOrderID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var r remark
|
||||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||
return ""
|
||||
}
|
||||
return r.OrderID
|
||||
}
|
||||
|
||||
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("平仓价格: %.2f", p.Price))
|
||||
}
|
||||
case "GAIN":
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %.2f", p.Price))
|
||||
}
|
||||
case "SELL":
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("卖出价格: %.2f", p.Price))
|
||||
}
|
||||
default:
|
||||
if p.Price > 0 {
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", p.Price))
|
||||
}
|
||||
}
|
||||
if p.LossPrice > 0 {
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %.2f", p.LossPrice))
|
||||
}
|
||||
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(time.Local)
|
||||
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 firstNonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user