feat(crypto策略): 支持巴菲特策略纯 rawMessage 信号推送
Motivation: 新增巴菲特激进/稳健策略(PUTEJJ/PUTEWJ)的跟单推送能力。该类信号仅有 rawMessage、无 action 字段,需按原文直接推送,并过滤启动文案、本地化时间字段。 Changes: * 支持无 action 的纯 rawMessage 信号,映射为 trade.message 事件 * 新增 not_contains 条件操作符,用于过滤启动文案 * 新增 replace 模板函数,将 Time: 替换为推送时间: * 补充巴菲特策略模板、规则与渠道配置文档
This commit is contained in:
@@ -100,7 +100,7 @@ make build && ./bin/server
|
||||
| `subscriptions[].source` | 对应已有 Source.name | 有 url 时必填 |
|
||||
| `subscriptions[].formatter` | 目前仅 `trade_signal` | `trade_signal` |
|
||||
|
||||
环境变量 `RABBITMQ_URL` 未设置时不启动消费,HTTP 通知不受影响。交易信号订阅需事先创建 Source(如 `trade-signal`)、模板(可用 `{{.formatted}}`)、规则 `trade.open` / `trade.add` / `trade.close` / `trade.reduce`、以及渠道。规则条件可用 `strategyCode` / `symbol` / `period`。跟单策略(B龙 `BLONG` 及后续)复用模板 `跟单策略`,规则 `trade.*` + `strategyCode`。
|
||||
环境变量 `RABBITMQ_URL` 未设置时不启动消费,HTTP 通知不受影响。交易信号订阅需事先创建 Source(如 `trade-signal`)、模板(可用 `{{.formatted}}`)、规则 `trade.open` / `trade.add` / `trade.close` / `trade.reduce`、以及渠道。规则条件可用 `strategyCode` / `symbol` / `period`。跟单策略(B龙 `BLONG` 及后续)复用模板 `跟单策略`,规则 `trade.*` + `strategyCode`。巴菲特激进 `PUTEJJ` / 稳健 `PUTEWJ` 用模板 `巴菲特策略`(`rawMessage`),发到巴菲特三群,并过滤含「启动」的文案。
|
||||
|
||||
`crypto-strategy` 开仓(多/空,含原来的 `isSale` 空单)都映射为 `trade.open`,不再发 `trade.sell`。止盈(`isGain`)为 `trade.gain`,止损(`isClose` 且非 `isGain`)为 `trade.close`。高低分 `HLSS`、异动 `AMA`、波段 `BTS`、AG 趋势 `AGTS` 用各自前缀:`HLSS.open` / `AMA.open` / `BTS.close` / `AGTS.open` 等。同一 Source 允许多条相同 event 的规则(用条件区分);精确 event 优先于通配,条件通过的规则都会发送。现成模板与规则见 `docs/httpie/curls.md`。
|
||||
|
||||
@@ -307,6 +307,8 @@ Body 同创建。成功:`{"ok": true}`
|
||||
|
||||
`case` 类似 switch:按值匹配成对的 key/文案,最后一个奇数参数是默认值。`trade.close` 能匹配 `.close` / `close` / `CLOSE`。
|
||||
|
||||
`replace` 做全文替换:`{{replace .rawMessage "Time:" "推送时间:"}}`。
|
||||
|
||||
```
|
||||
### {{.symbol}} {{case .action "OPEN" "开仓" "CLOSE" "平仓" "GAIN" "止盈" "SELL" "卖出" "ADD" "加仓" "REDUCE" "减仓"}}
|
||||
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}{{line "推送时间" .pushedAt}}
|
||||
@@ -518,7 +520,7 @@ Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page"
|
||||
| `eq` / `ne` | 等于 / 不等于(字符串比较) |
|
||||
| `gt` / `gte` / `lt` / `lte` | 数值比较 |
|
||||
| `exists` / `not_exists` | 字段是否存在 |
|
||||
| `contains` | 字符串包含 |
|
||||
| `contains` / `not_contains` | 字符串包含 / 不包含 |
|
||||
|
||||
条件结构:`{"field":"<字段>","op":"<操作符>","value":"<可选>"}`
|
||||
|
||||
|
||||
@@ -718,3 +718,92 @@ curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 巴菲特激进 / 稳健(trade-signal)
|
||||
|
||||
Source:`trade-signal`。渠道:`safew_巴菲特策略`、`safew_巴菲特策略2`、`safew_巴菲特策略3`(三个不同群;`巴菲特02/03` 与策略2/3 是同一 chat,不要重复绑)。
|
||||
|
||||
策略 code:激进 `PUTEJJ`、稳健 `PUTEWJ`,共用上游 `rawMessage`(激进版 / 稳健版文案已在原文里)。加仓也是「市价开多/开空」,走开仓规则。
|
||||
|
||||
兜底 `rule-11` 需 `strategyCode ne PUTEJJ` 且 `ne PUTEWJ`,避免再发到测试AI。
|
||||
|
||||
启动文案(`普达特量化机器人…启动`)不含「市价开 / 平仓 / 本周期」,三条规则都匹配不上,等于过滤。
|
||||
|
||||
开仓 `rawMessage` 示例:
|
||||
|
||||
```
|
||||
激进版AI 1.0
|
||||
市价开空
|
||||
交易品种: BTC
|
||||
开空数量: 1.00
|
||||
开空价格: 63578.00
|
||||
持仓数量: 1.00
|
||||
平均价格: 63578.00
|
||||
浮动盈亏: 0.00
|
||||
账户净值: 101286.40
|
||||
账户余额:101286.40
|
||||
Time: 2026.08.17 14:46:04
|
||||
```
|
||||
|
||||
### 创建模板
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/templates' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特策略",
|
||||
"content": "{{if .rawMessage}}{{replace .rawMessage \"Time:\" \"推送时间:\"}}{{else}}{{.formatted}}{{end}}"
|
||||
}'
|
||||
```
|
||||
|
||||
线上若尚未部署 `replace`,先用 `{{if .rawMessage}}{{.rawMessage}}{{else}}{{.formatted}}{{end}}`。
|
||||
|
||||
### 创建开仓 / 平仓 / 提现规则
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特开仓",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "市价开"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特平仓",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "平仓"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/rules' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "巴菲特提现",
|
||||
"source_name": "trade-signal",
|
||||
"event": "trade.*",
|
||||
"template_name": "巴菲特策略",
|
||||
"channels": ["safew_巴菲特策略", "safew_巴菲特策略2", "safew_巴菲特策略3"],
|
||||
"conditions": [{"field": "rawMessage", "op": "contains", "value": "本周期"}],
|
||||
"enabled": 1
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -46,6 +46,11 @@ func evaluateOne(c model.Condition, data map[string]interface{}) bool {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value)
|
||||
case "not_contains":
|
||||
if !fieldExists {
|
||||
return true
|
||||
}
|
||||
return !strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value)
|
||||
case "gt", "gte", "lt", "lte":
|
||||
if !fieldExists {
|
||||
return false
|
||||
|
||||
@@ -54,3 +54,16 @@ func TestEvaluate_Contains(t *testing.T) {
|
||||
t.Error("msg contains 'error', should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_NotContains(t *testing.T) {
|
||||
conds := []model.Condition{{Field: "rawMessage", Op: "not_contains", Value: "启动"}}
|
||||
if !Evaluate(conds, map[string]interface{}{"rawMessage": "激进版AI 1.0\n市价开空"}) {
|
||||
t.Fatal("open text should pass")
|
||||
}
|
||||
if Evaluate(conds, map[string]interface{}{"rawMessage": "普达特量化机器人激进版启动\n账户余额:100000.00"}) {
|
||||
t.Fatal("startup text should be filtered")
|
||||
}
|
||||
if !Evaluate(conds, map[string]interface{}{"strategyCode": "PUTEJJ"}) {
|
||||
t.Fatal("missing rawMessage should pass")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (stri
|
||||
tmpl, err := template.New("notify").
|
||||
Option("missingkey=zero").
|
||||
Funcs(template.FuncMap{
|
||||
"line": templateLine,
|
||||
"case": templateCase,
|
||||
"line": templateLine,
|
||||
"case": templateCase,
|
||||
"replace": strings.ReplaceAll,
|
||||
}).
|
||||
Parse(tmplContent)
|
||||
if err != nil {
|
||||
|
||||
@@ -98,6 +98,19 @@ func TestRendererCaseEventSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererReplace(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{replace .rawMessage "Time:" "推送时间:"}}`, map[string]interface{}{
|
||||
"rawMessage": "激进版AI 1.0\n市价开空\nTime: 2026.08.17 14:46:04",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "推送时间: 2026.08.17 14:46:04") || strings.Contains(out, "Time:") {
|
||||
t.Fatalf("out=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererCaseDefault(t *testing.T) {
|
||||
r := NewRenderer()
|
||||
out, err := r.Render(`{{case .action "OPEN" "开仓" "未知"}}`, map[string]interface{}{"action": "HOLD"})
|
||||
|
||||
@@ -29,7 +29,14 @@ func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error)
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrInvalidSignal, err)
|
||||
}
|
||||
if strings.TrimSpace(sig.Action) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
if strings.TrimSpace(sig.RawMessage) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
}
|
||||
data, err := toData(body, &sig)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "trade.message", data, nil
|
||||
}
|
||||
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
|
||||
snap := c.positions.Apply(out)
|
||||
|
||||
@@ -62,3 +62,19 @@ func TestConvertMissingAction(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRawMessageWithoutAction(t *testing.T) {
|
||||
event, data, err := NewConverter(nil).Convert([]byte(`{
|
||||
"strategyCode":"PUTEJJ",
|
||||
"rawMessage":"4.7.15~2026.8.15 本周期 10万本金 期末109640,盈利9.6%"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.message" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
if data["rawMessage"] != "4.7.15~2026.8.15 本周期 10万本金 期末109640,盈利9.6%" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Signal struct {
|
||||
StopLossRatio *float64 `json:"stopLossRatio"`
|
||||
PnL *float64 `json:"pnl"`
|
||||
AccountBalance *float64 `json:"accountBalance"`
|
||||
RawMessage string `json:"rawMessage"`
|
||||
}
|
||||
|
||||
func (s *Signal) ParsedEventTime() time.Time {
|
||||
|
||||
Reference in New Issue
Block a user