diff --git a/README.md b/README.md index f398c8b..d84c0dc 100644 --- a/README.md +++ b/README.md @@ -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":"<可选>"}` diff --git a/docs/httpie/curls.md b/docs/httpie/curls.md index 91424d1..d9db9d2 100644 --- a/docs/httpie/curls.md +++ b/docs/httpie/curls.md @@ -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 + }' +``` diff --git a/internal/condition/evaluator.go b/internal/condition/evaluator.go index 41957c2..d2815b6 100644 --- a/internal/condition/evaluator.go +++ b/internal/condition/evaluator.go @@ -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 diff --git a/internal/condition/evaluator_test.go b/internal/condition/evaluator_test.go index 310815b..d937f09 100644 --- a/internal/condition/evaluator_test.go +++ b/internal/condition/evaluator_test.go @@ -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") + } +} diff --git a/internal/engine/renderer.go b/internal/engine/renderer.go index db83c63..1217ebd 100644 --- a/internal/engine/renderer.go +++ b/internal/engine/renderer.go @@ -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 { diff --git a/internal/engine/renderer_test.go b/internal/engine/renderer_test.go index 24a297f..aaab2c7 100644 --- a/internal/engine/renderer_test.go +++ b/internal/engine/renderer_test.go @@ -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"}) diff --git a/internal/subscriber/tradesignal/convert.go b/internal/subscriber/tradesignal/convert.go index 852600a..946833a 100644 --- a/internal/subscriber/tradesignal/convert.go +++ b/internal/subscriber/tradesignal/convert.go @@ -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) diff --git a/internal/subscriber/tradesignal/convert_test.go b/internal/subscriber/tradesignal/convert_test.go index 6de94a8..6ebce9a 100644 --- a/internal/subscriber/tradesignal/convert_test.go +++ b/internal/subscriber/tradesignal/convert_test.go @@ -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) + } +} diff --git a/internal/subscriber/tradesignal/signal.go b/internal/subscriber/tradesignal/signal.go index 387ae9e..8c8cb4f 100644 --- a/internal/subscriber/tradesignal/signal.go +++ b/internal/subscriber/tradesignal/signal.go @@ -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 {