feat: subscribe to RabbitMQ trade signals and notify by rules
Consume configurable queues, format signals (including period), share NotifyService with HTTP, and drop duplicate bodies within 1h.
This commit is contained in:
@@ -95,6 +95,12 @@ make build && ./bin/server
|
||||
| `smtp.*` | 邮件发送(email 渠道) | — |
|
||||
| `rate_limit.default` | 每 source 每秒请求上限 | `100` |
|
||||
| `rate_limit.dingtalk_per_min` | 同一钉钉机器人(access_token)每分钟发送上限;超限排队到下一分钟 | `18`(官方 20,留余量) |
|
||||
| `subscription_dedup_ttl` | 多队列重复消息(body SHA-256)去重窗口 | `1h` |
|
||||
| `subscriptions` | RabbitMQ 订阅列表;某条 `url` 为空则跳过 | 空 |
|
||||
| `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`。
|
||||
|
||||
健康检查:`GET /health` → `{"status":"ok"}`
|
||||
|
||||
|
||||
+31
-7
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -15,8 +16,11 @@ import (
|
||||
"aiaa-notification-service/internal/config"
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/handler"
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/safew"
|
||||
"aiaa-notification-service/internal/store"
|
||||
"aiaa-notification-service/internal/subscriber"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/logbull/logbull-go/logbull"
|
||||
@@ -123,7 +127,8 @@ func main() {
|
||||
router := engine.NewRouter(st, redisCache, senderFactory)
|
||||
|
||||
// Build handlers
|
||||
notifyH := handler.NewNotifyHandler(st, redisCache, matcher, renderer, router)
|
||||
notifySvc := notify.NewService(matcher, st, renderer, router, st)
|
||||
notifyH := handler.NewNotifyHandler(notifySvc)
|
||||
sourceH := handler.NewSourceHandler(st, redisCache)
|
||||
templateH := handler.NewTemplateHandler(st, redisCache)
|
||||
|
||||
@@ -217,6 +222,28 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
lookup := func(ctx context.Context, name string) (*model.Source, error) {
|
||||
return st.GetSourceByName(ctx, name)
|
||||
}
|
||||
deduper := subscriber.NewCacheDeduper(redisCache, cfg.SubscriptionDedupTTL)
|
||||
for _, sub := range cfg.ActiveSubscriptions() {
|
||||
sub := sub
|
||||
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper)
|
||||
if err != nil {
|
||||
slog.Error("subscriber init", "name", sub.Name, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go func() {
|
||||
if err := cons.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("subscriber stopped", "name", sub.Name, "error", err)
|
||||
}
|
||||
}()
|
||||
slog.Info("subscriber started", "name", sub.Name, "queue", sub.Queue, "source", sub.Source)
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
@@ -225,15 +252,12 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("forced shutdown", "error", err)
|
||||
}
|
||||
slog.Info("server stopped")
|
||||
|
||||
@@ -33,3 +33,26 @@ logbull:
|
||||
project_id: "42a3fef0-2fd6-4ce4-80c5-f6bb6ecc2013"
|
||||
api_key: "lb_60701971723797ed0374aa3896078fe5"
|
||||
log_level: "INFO"
|
||||
|
||||
# 多队列重复消息按 body SHA-256 去重;有 Redis 时跨进程共享
|
||||
subscription_dedup_ttl: 1h
|
||||
|
||||
subscriptions:
|
||||
- name: trade-signal
|
||||
url: "${RABBITMQ_URL}"
|
||||
queue: trade.signal.notify.queue
|
||||
dead_letter_queue: trade.signal.notify.dlq
|
||||
exchange: trade.signal.executor.queue
|
||||
exchange_type: fanout
|
||||
routing_key: ""
|
||||
max_retry: 3
|
||||
source: trade-signal
|
||||
formatter: trade_signal
|
||||
strategy_overrides:
|
||||
BLONG:
|
||||
quantity_multipliers:
|
||||
open: 100
|
||||
add: 100
|
||||
reduce: 100
|
||||
close: 100
|
||||
leverage: 100
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/logbull/logbull-go v0.2.0
|
||||
github.com/rabbitmq/amqp091-go v1.13.0
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
@@ -81,6 +81,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
|
||||
Vendored
+12
@@ -175,3 +175,15 @@ func (c *Cache) CheckRateLimit(ctx context.Context, sourceID int, limitPerSec in
|
||||
func (c *Cache) Close() error {
|
||||
return c.rdb.Close()
|
||||
}
|
||||
|
||||
func dedupKey(hash string) string {
|
||||
return "notify:dedup:" + hash
|
||||
}
|
||||
|
||||
func (c *Cache) ClaimDedup(ctx context.Context, hash string, ttl time.Duration) (bool, error) {
|
||||
return c.rdb.SetNX(ctx, dedupKey(hash), "1", ttl).Result()
|
||||
}
|
||||
|
||||
func (c *Cache) ReleaseDedup(ctx context.Context, hash string) error {
|
||||
return c.rdb.Del(ctx, dedupKey(hash)).Err()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -15,6 +16,95 @@ type Config struct {
|
||||
SMTP SMTPConfig `mapstructure:"smtp"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Logbull LogbullConfig `mapstructure:"logbull"`
|
||||
Subscriptions []SubscriptionConfig `mapstructure:"subscriptions"`
|
||||
SubscriptionDedupTTL time.Duration `mapstructure:"subscription_dedup_ttl"`
|
||||
}
|
||||
|
||||
type SubscriptionConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Queue string `mapstructure:"queue"`
|
||||
DeadLetterQueue string `mapstructure:"dead_letter_queue"`
|
||||
Exchange string `mapstructure:"exchange"`
|
||||
ExchangeType string `mapstructure:"exchange_type"`
|
||||
RoutingKey string `mapstructure:"routing_key"`
|
||||
MaxRetry int `mapstructure:"max_retry"`
|
||||
Source string `mapstructure:"source"`
|
||||
Formatter string `mapstructure:"formatter"`
|
||||
StrategyOverrides map[string]StrategyOverride `mapstructure:"strategy_overrides"`
|
||||
}
|
||||
|
||||
type StrategyOverride struct {
|
||||
QuantityMultipliers QuantityMultipliers `mapstructure:"quantity_multipliers"`
|
||||
Leverage *int `mapstructure:"leverage"`
|
||||
}
|
||||
|
||||
type QuantityMultipliers struct {
|
||||
Open float64 `mapstructure:"open"`
|
||||
Add float64 `mapstructure:"add"`
|
||||
Reduce float64 `mapstructure:"reduce"`
|
||||
Close float64 `mapstructure:"close"`
|
||||
}
|
||||
|
||||
func (o StrategyOverride) QuantityMultiplierFor(action string) float64 {
|
||||
var v float64
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
v = o.QuantityMultipliers.Open
|
||||
case "ADD":
|
||||
v = o.QuantityMultipliers.Add
|
||||
case "REDUCE":
|
||||
v = o.QuantityMultipliers.Reduce
|
||||
case "CLOSE":
|
||||
v = o.QuantityMultipliers.Close
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeSubscriptions() error {
|
||||
for i := range c.Subscriptions {
|
||||
s := &c.Subscriptions[i]
|
||||
if s.URL == "" {
|
||||
continue
|
||||
}
|
||||
if s.Queue == "" {
|
||||
return fmt.Errorf("subscriptions[%d]: queue is required", i)
|
||||
}
|
||||
if s.Source == "" {
|
||||
return fmt.Errorf("subscriptions[%d]: source is required", i)
|
||||
}
|
||||
if s.Name == "" {
|
||||
s.Name = s.Queue
|
||||
}
|
||||
if s.MaxRetry <= 0 {
|
||||
s.MaxRetry = 3
|
||||
}
|
||||
if s.ExchangeType == "" {
|
||||
s.ExchangeType = "fanout"
|
||||
}
|
||||
if s.Formatter == "" {
|
||||
s.Formatter = "trade_signal"
|
||||
}
|
||||
if s.Formatter != "trade_signal" {
|
||||
return fmt.Errorf("subscriptions[%d]: unknown formatter %q", i, s.Formatter)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ActiveSubscriptions() []SubscriptionConfig {
|
||||
out := make([]SubscriptionConfig, 0, len(c.Subscriptions))
|
||||
for _, s := range c.Subscriptions {
|
||||
if s.URL != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -90,6 +180,12 @@ func Load(path string) (*Config, error) {
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.SubscriptionDedupTTL <= 0 {
|
||||
cfg.SubscriptionDedupTTL = time.Hour
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeSubscriptionDefaults(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
URL: "amqps://example.invalid/vhost",
|
||||
Queue: "trade.signal.notify.queue",
|
||||
Source: "trade-signal",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := cfg.Subscriptions[0]
|
||||
if s.Name != "trade.signal.notify.queue" {
|
||||
t.Fatalf("name=%q", s.Name)
|
||||
}
|
||||
if s.MaxRetry != 3 {
|
||||
t.Fatalf("max_retry=%d", s.MaxRetry)
|
||||
}
|
||||
if s.ExchangeType != "fanout" {
|
||||
t.Fatalf("exchange_type=%q", s.ExchangeType)
|
||||
}
|
||||
if s.Formatter != "trade_signal" {
|
||||
t.Fatalf("formatter=%q", s.Formatter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkipsEmptyURL(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
Queue: "q", Source: "s",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(cfg.ActiveSubscriptions()); n != 0 {
|
||||
t.Fatalf("active=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUnknownFormatter(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
URL: "amqps://example.invalid/vhost", Queue: "q", Source: "s", Formatter: "other",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuantityMultiplierFor(t *testing.T) {
|
||||
o := StrategyOverride{QuantityMultipliers: QuantityMultipliers{Open: 100, Add: 0}}
|
||||
if o.QuantityMultiplierFor("OPEN") != 100 {
|
||||
t.Fatalf("open=%v", o.QuantityMultiplierFor("OPEN"))
|
||||
}
|
||||
if o.QuantityMultiplierFor("ADD") != 1 {
|
||||
t.Fatalf("add<=0 should be 1, got %v", o.QuantityMultiplierFor("ADD"))
|
||||
}
|
||||
if o.QuantityMultiplierFor("UNKNOWN") != 1 {
|
||||
t.Fatalf("unknown=%v", o.QuantityMultiplierFor("UNKNOWN"))
|
||||
}
|
||||
}
|
||||
+19
-101
@@ -1,47 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
"aiaa-notification-service/internal/condition"
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/parser"
|
||||
"aiaa-notification-service/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotifyHandler struct {
|
||||
store *store.Store
|
||||
cache *cache.Cache // used by engine
|
||||
matcher *engine.Matcher
|
||||
renderer *engine.Renderer
|
||||
router *engine.Router
|
||||
svc *notify.Service
|
||||
}
|
||||
|
||||
func NewNotifyHandler(s *store.Store, c *cache.Cache, m *engine.Matcher, r *engine.Renderer, rt *engine.Router) *NotifyHandler {
|
||||
return &NotifyHandler{store: s, cache: c, matcher: m, renderer: r, router: rt}
|
||||
func NewNotifyHandler(svc *notify.Service) *NotifyHandler {
|
||||
return &NotifyHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *NotifyHandler) Handle(c *gin.Context) {
|
||||
src := c.MustGet("source").(*model.Source)
|
||||
|
||||
// 1. Read raw body
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Parse message
|
||||
p, err := parser.NewParser(src.ParseMode, src.ParsePattern)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parser setup: " + err.Error()})
|
||||
@@ -53,93 +40,24 @@ func (h *NotifyHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Match rule
|
||||
rule, err := h.matcher.Match(c.Request.Context(), src.ID, msg.Event)
|
||||
res, err := h.svc.Process(c.Request.Context(), notify.Request{
|
||||
Source: src, Event: msg.Event, Data: msg.Data,
|
||||
})
|
||||
if err != nil {
|
||||
// No matching rule → 200 with matched: false
|
||||
if errors.Is(err, notify.ErrUnprocessable) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !res.Matched {
|
||||
c.JSON(http.StatusOK, gin.H{"matched": false})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Evaluate conditions
|
||||
if rule.Conditions != nil {
|
||||
var conds []model.Condition
|
||||
if err := json.Unmarshal(*rule.Conditions, &conds); err != nil {
|
||||
slog.Error("failed to unmarshal rule conditions", "rule_id", rule.ID, "error", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid rule conditions"})
|
||||
if res.Filtered {
|
||||
c.JSON(http.StatusOK, gin.H{"matched": true, "filtered": true, "reason": res.Reason})
|
||||
return
|
||||
}
|
||||
if !condition.Evaluate(conds, msg.Data) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"matched": true,
|
||||
"filtered": true,
|
||||
"reason": "condition not met",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Get template content
|
||||
tmpl, err := h.store.GetTemplate(c.Request.Context(), rule.TemplateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Render template
|
||||
content, err := h.renderer.Render(tmpl.Content, msg.Data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "template render failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Route to channels
|
||||
title := src.Name + ": " + msg.Event
|
||||
channels := h.router.Route(c.Request.Context(), rule, title, content)
|
||||
|
||||
// 8. Log message (best effort, detached context)
|
||||
go func() {
|
||||
payloadJSON, _ := json.Marshal(msg.Data)
|
||||
ctx := context.Background()
|
||||
for _, chName := range channels {
|
||||
chID := parseChannelID(chName)
|
||||
ml := &model.MessageLog{
|
||||
RuleID: rule.ID,
|
||||
ChannelID: chID,
|
||||
Source: src.Name,
|
||||
Event: msg.Event,
|
||||
Payload: payloadJSON,
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
}
|
||||
if err := h.store.CreateMessageLog(ctx, ml); err != nil {
|
||||
slog.Warn("failed to create message log", "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("notification accepted",
|
||||
"source", src.Name,
|
||||
"event", msg.Event,
|
||||
"channels", channels,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"matched": true,
|
||||
"channels": channels,
|
||||
"accepted": true,
|
||||
})
|
||||
}
|
||||
|
||||
// parseChannelID extracts the numeric channel ID from a channel name formatted as "type:id".
|
||||
func parseChannelID(chName string) int {
|
||||
idx := strings.LastIndex(chName, ":")
|
||||
if idx < 0 || idx == len(chName)-1 {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.Atoi(chName[idx+1:])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
c.JSON(http.StatusOK, gin.H{"matched": true, "channels": res.Channels, "accepted": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/condition"
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
var ErrUnprocessable = errors.New("unprocessable")
|
||||
|
||||
type Request struct {
|
||||
Source *model.Source
|
||||
Event string
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Matched bool
|
||||
Filtered bool
|
||||
Channels []string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type RuleMatcher interface {
|
||||
Match(ctx context.Context, sourceID int, event string) (*model.Rule, error)
|
||||
}
|
||||
|
||||
type TemplateStore interface {
|
||||
GetTemplate(ctx context.Context, id int) (*model.Template, error)
|
||||
}
|
||||
|
||||
type ChannelRouter interface {
|
||||
Route(ctx context.Context, rule *model.Rule, title, content string) []string
|
||||
}
|
||||
|
||||
type MessageLogger interface {
|
||||
CreateMessageLog(ctx context.Context, ml *model.MessageLog) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
matcher RuleMatcher
|
||||
templates TemplateStore
|
||||
renderer *engine.Renderer
|
||||
router ChannelRouter
|
||||
logs MessageLogger
|
||||
}
|
||||
|
||||
func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRouter, logs MessageLogger) *Service {
|
||||
return &Service{matcher: m, templates: t, renderer: r, router: rt, logs: logs}
|
||||
}
|
||||
|
||||
func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
|
||||
rule, err := s.matcher.Match(ctx, req.Source.ID, req.Event)
|
||||
if err != nil {
|
||||
return Result{Matched: false}, nil
|
||||
}
|
||||
|
||||
if rule.Conditions != nil {
|
||||
var conds []model.Condition
|
||||
if err := json.Unmarshal(*rule.Conditions, &conds); err != nil {
|
||||
slog.Error("failed to unmarshal rule conditions", "rule_id", rule.ID, "error", err)
|
||||
return Result{}, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable)
|
||||
}
|
||||
if !condition.Evaluate(conds, req.Data) {
|
||||
return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := s.templates.GetTemplate(ctx, rule.TemplateID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("template not found")
|
||||
}
|
||||
|
||||
content, err := s.renderer.Render(tmpl.Content, req.Data)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error())
|
||||
}
|
||||
|
||||
title := req.Source.Name + ": " + req.Event
|
||||
channels := s.router.Route(ctx, rule, title, content)
|
||||
|
||||
if s.logs != nil {
|
||||
go func() {
|
||||
payloadJSON, _ := json.Marshal(req.Data)
|
||||
logCtx := context.Background()
|
||||
for _, chName := range channels {
|
||||
ml := &model.MessageLog{
|
||||
RuleID: rule.ID,
|
||||
ChannelID: parseChannelID(chName),
|
||||
Source: req.Source.Name,
|
||||
Event: req.Event,
|
||||
Payload: payloadJSON,
|
||||
Content: content,
|
||||
Status: "pending",
|
||||
}
|
||||
if err := s.logs.CreateMessageLog(logCtx, ml); err != nil {
|
||||
slog.Warn("failed to create message log", "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("notification accepted",
|
||||
"source", req.Source.Name,
|
||||
"event", req.Event,
|
||||
"channels", channels,
|
||||
)
|
||||
|
||||
return Result{Matched: true, Channels: channels}, nil
|
||||
}
|
||||
|
||||
func parseChannelID(chName string) int {
|
||||
idx := strings.LastIndex(chName, ":")
|
||||
if idx < 0 || idx == len(chName)-1 {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.Atoi(chName[idx+1:])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/engine"
|
||||
"aiaa-notification-service/internal/model"
|
||||
)
|
||||
|
||||
type fakeMatcher struct {
|
||||
rule *model.Rule
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMatcher) Match(context.Context, int, string) (*model.Rule, error) {
|
||||
return f.rule, f.err
|
||||
}
|
||||
|
||||
type fakeTemplates struct {
|
||||
tmpl *model.Template
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTemplates) GetTemplate(context.Context, int) (*model.Template, error) {
|
||||
return f.tmpl, f.err
|
||||
}
|
||||
|
||||
type fakeRouter struct{ channels []string }
|
||||
|
||||
func (f *fakeRouter) Route(context.Context, *model.Rule, string, string) []string {
|
||||
return f.channels
|
||||
}
|
||||
|
||||
type fakeLogs struct{}
|
||||
|
||||
func (f *fakeLogs) CreateMessageLog(context.Context, *model.MessageLog) error { return nil }
|
||||
|
||||
func newSvc(m *fakeMatcher, t *fakeTemplates, rt *fakeRouter) *Service {
|
||||
return NewService(m, t, engine.NewRenderer(), rt, &fakeLogs{})
|
||||
}
|
||||
|
||||
func TestProcessNoRule(t *testing.T) {
|
||||
svc := newSvc(&fakeMatcher{err: errors.New("no rule")}, &fakeTemplates{}, &fakeRouter{})
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"symbol": "BTCUSDT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Matched {
|
||||
t.Fatal("expected unmatched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFiltered(t *testing.T) {
|
||||
raw := json.RawMessage(`[{"field":"symbol","op":"eq","value":"ETHUSDT"}]`)
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"symbol": "BTCUSDT"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || !res.Filtered || res.Reason != "condition not met" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMatched(t *testing.T) {
|
||||
svc := newSvc(
|
||||
&fakeMatcher{rule: &model.Rule{ID: 9, TemplateID: 1}},
|
||||
&fakeTemplates{tmpl: &model.Template{ID: 1, Content: "{{.formatted}}"}},
|
||||
&fakeRouter{channels: []string{"dingtalk:3"}},
|
||||
)
|
||||
res, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "trade-signal"},
|
||||
Event: "trade.open",
|
||||
Data: map[string]interface{}{"formatted": "多单开仓"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Matched || res.Filtered || len(res.Channels) != 1 || res.Channels[0] != "dingtalk:3" {
|
||||
t.Fatalf("%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInvalidConditions(t *testing.T) {
|
||||
raw := json.RawMessage(`not-json`)
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1, Conditions: &raw}}, &fakeTemplates{}, &fakeRouter{})
|
||||
_, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "s"},
|
||||
Event: "e",
|
||||
Data: map[string]interface{}{},
|
||||
})
|
||||
if !errors.Is(err, ErrUnprocessable) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTemplateMissing(t *testing.T) {
|
||||
svc := newSvc(&fakeMatcher{rule: &model.Rule{ID: 1, TemplateID: 1}}, &fakeTemplates{err: errors.New("nope")}, &fakeRouter{})
|
||||
_, err := svc.Process(context.Background(), Request{
|
||||
Source: &model.Source{ID: 1, Name: "s"},
|
||||
Event: "e",
|
||||
Data: map[string]interface{}{},
|
||||
})
|
||||
if err == nil || errors.Is(err, ErrUnprocessable) {
|
||||
t.Fatalf("want retryable error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package subscriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/cache"
|
||||
)
|
||||
|
||||
type Deduper interface {
|
||||
Claim(ctx context.Context, hash string) (bool, error)
|
||||
Release(ctx context.Context, hash string) error
|
||||
}
|
||||
|
||||
func MessageHash(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type MemoryDeduper struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func NewMemoryDeduper() *MemoryDeduper {
|
||||
return &MemoryDeduper{seen: make(map[string]struct{})}
|
||||
}
|
||||
|
||||
func (d *MemoryDeduper) Claim(_ context.Context, hash string) (bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if _, ok := d.seen[hash]; ok {
|
||||
return false, nil
|
||||
}
|
||||
d.seen[hash] = struct{}{}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (d *MemoryDeduper) Release(_ context.Context, hash string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.seen, hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
type cacheDeduper struct {
|
||||
c *cache.Cache
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewCacheDeduper(c *cache.Cache, ttl time.Duration) Deduper {
|
||||
if c == nil {
|
||||
return NewMemoryDeduper()
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
return &cacheDeduper{c: c, ttl: ttl}
|
||||
}
|
||||
|
||||
func (d *cacheDeduper) Claim(ctx context.Context, hash string) (bool, error) {
|
||||
return d.c.ClaimDedup(ctx, hash, d.ttl)
|
||||
}
|
||||
|
||||
func (d *cacheDeduper) Release(ctx context.Context, hash string) error {
|
||||
return d.c.ReleaseDedup(ctx, hash)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package subscriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
)
|
||||
|
||||
func TestMessageHashStable(t *testing.T) {
|
||||
a := MessageHash([]byte(`{"action":"OPEN","signalId":"s1"}`))
|
||||
b := MessageHash([]byte(`{"action":"OPEN","signalId":"s1"}`))
|
||||
c := MessageHash([]byte(`{"action":"CLOSE","signalId":"s1"}`))
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("hash should be stable, a=%q b=%q", a, b)
|
||||
}
|
||||
if a == c {
|
||||
t.Fatal("different bodies should hash differently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDeduperClaimOnce(t *testing.T) {
|
||||
d := NewMemoryDeduper()
|
||||
ok, err := d.Claim(context.Background(), "abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first claim ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = d.Claim(context.Background(), "abc")
|
||||
if err != nil || ok {
|
||||
t.Fatalf("second claim should miss, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := d.Release(context.Background(), "abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err = d.Claim(context.Background(), "abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("after release should claim, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDuplicateAckSkipsProcess(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var n atomic.Int32
|
||||
process := func(context.Context, notify.Request) (notify.Result, error) {
|
||||
n.Add(1)
|
||||
return notify.Result{Matched: true}, nil
|
||||
}
|
||||
lookup := func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil }
|
||||
conv := tradesignal.NewConverter(nil)
|
||||
body := []byte(`{"action":"OPEN","signalId":"dup-1"}`)
|
||||
in := HandleInput{Body: body, SourceName: "trade-signal", MaxRetry: 3, Deduper: dedup}
|
||||
|
||||
if d := HandleMessage(context.Background(), in, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("first=%v", d)
|
||||
}
|
||||
if d := HandleMessage(context.Background(), in, conv, lookup, process); d != DispositionAck {
|
||||
t.Fatalf("dup=%v", d)
|
||||
}
|
||||
if n.Load() != 1 {
|
||||
t.Fatalf("process called %d times, want 1", n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProcessErrorReleasesDedup(t *testing.T) {
|
||||
dedup := NewMemoryDeduper()
|
||||
var n atomic.Int32
|
||||
process := func(context.Context, notify.Request) (notify.Result, error) {
|
||||
n.Add(1)
|
||||
return notify.Result{}, errors.New("db down")
|
||||
}
|
||||
lookup := func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil }
|
||||
conv := tradesignal.NewConverter(nil)
|
||||
body := []byte(`{"action":"OPEN","signalId":"retry-1"}`)
|
||||
in := HandleInput{Body: body, SourceName: "s", MaxRetry: 3, Deduper: dedup}
|
||||
|
||||
if d := HandleMessage(context.Background(), in, conv, lookup, process); d != DispositionRetry {
|
||||
t.Fatalf("first=%v", d)
|
||||
}
|
||||
if d := HandleMessage(context.Background(), in, conv, lookup, process); d != DispositionRetry {
|
||||
t.Fatalf("retry should process again, got %v", d)
|
||||
}
|
||||
if n.Load() != 2 {
|
||||
t.Fatalf("process called %d times, want 2", n.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package subscriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
)
|
||||
|
||||
const retryHeader = "x-retry-count"
|
||||
|
||||
type Disposition int
|
||||
|
||||
const (
|
||||
DispositionAck Disposition = iota
|
||||
DispositionRetry
|
||||
DispositionDLQ
|
||||
)
|
||||
|
||||
type SourceLookup func(ctx context.Context, name string) (*model.Source, error)
|
||||
|
||||
type ProcessFunc func(ctx context.Context, req notify.Request) (notify.Result, error)
|
||||
|
||||
type HandleInput struct {
|
||||
Body []byte
|
||||
Headers map[string]any
|
||||
SourceName string
|
||||
MaxRetry int
|
||||
Deduper Deduper
|
||||
}
|
||||
|
||||
func DecideRetry(retryCount, maxRetry int) Disposition {
|
||||
if retryCount+1 > maxRetry {
|
||||
return DispositionDLQ
|
||||
}
|
||||
return DispositionRetry
|
||||
}
|
||||
|
||||
func RetryCount(headers map[string]any) int {
|
||||
if headers == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := headers[retryHeader]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(n)
|
||||
return i
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func HandleMessage(ctx context.Context, in HandleInput, conv *tradesignal.Converter, lookup SourceLookup, process ProcessFunc) Disposition {
|
||||
owned := false
|
||||
hash := ""
|
||||
if in.Deduper != nil {
|
||||
hash = MessageHash(in.Body)
|
||||
ok, err := in.Deduper.Claim(ctx, hash)
|
||||
if err != nil {
|
||||
slog.Warn("dedup claim failed, processing anyway", "hash", hash, "error", err)
|
||||
} else if !ok {
|
||||
slog.Info("duplicate message, ack", "hash", hash)
|
||||
return DispositionAck
|
||||
} else {
|
||||
owned = true
|
||||
}
|
||||
}
|
||||
|
||||
event, data, err := conv.Convert(in.Body)
|
||||
if err != nil {
|
||||
slog.Warn("invalid signal, ack", "error", err)
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
src, err := lookup(ctx, in.SourceName)
|
||||
if err != nil || src == nil || src.Status != 1 {
|
||||
slog.Warn("source unavailable, ack", "source", in.SourceName, "error", err)
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
res, err := process(ctx, notify.Request{Source: src, Event: event, Data: data})
|
||||
if err == nil {
|
||||
if !res.Matched {
|
||||
slog.Info("no matching rule", "source", src.Name, "event", event)
|
||||
} else if res.Filtered {
|
||||
slog.Info("rule filtered", "source", src.Name, "event", event, "reason", res.Reason)
|
||||
}
|
||||
return DispositionAck
|
||||
}
|
||||
if errors.Is(err, notify.ErrUnprocessable) {
|
||||
slog.Warn("unprocessable notify, ack", "source", src.Name, "event", event, "error", err)
|
||||
return DispositionAck
|
||||
}
|
||||
|
||||
disp := DecideRetry(RetryCount(in.Headers), in.MaxRetry)
|
||||
if owned && in.Deduper != nil {
|
||||
if relErr := in.Deduper.Release(ctx, hash); relErr != nil {
|
||||
slog.Warn("dedup release failed", "hash", hash, "error", relErr)
|
||||
}
|
||||
}
|
||||
return disp
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package subscriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/model"
|
||||
"aiaa-notification-service/internal/notify"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
)
|
||||
|
||||
func TestDecideRetry(t *testing.T) {
|
||||
if DecideRetry(0, 3) != DispositionRetry {
|
||||
t.Fatal("first failure should retry")
|
||||
}
|
||||
if DecideRetry(3, 3) != DispositionDLQ {
|
||||
t.Fatal("retry 4 > 3 should dlq")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryCount(t *testing.T) {
|
||||
if RetryCount(nil) != 0 {
|
||||
t.Fatal()
|
||||
}
|
||||
if RetryCount(map[string]any{"x-retry-count": int32(2)}) != 2 {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func enabledSrc() *model.Source {
|
||||
return &model.Source{ID: 1, Name: "trade-signal", Status: 1}
|
||||
}
|
||||
|
||||
func TestHandleInvalidJSONAck(t *testing.T) {
|
||||
d := HandleMessage(context.Background(), HandleInput{Body: []byte(`{`), SourceName: "trade-signal", MaxRetry: 3},
|
||||
tradesignal.NewConverter(nil),
|
||||
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
|
||||
func(context.Context, notify.Request) (notify.Result, error) {
|
||||
t.Fatal("process should not run")
|
||||
return notify.Result{}, nil
|
||||
})
|
||||
if d != DispositionAck {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMissingSourceAck(t *testing.T) {
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: []byte(`{"action":"OPEN","symbol":"BTCUSDT"}`), SourceName: "trade-signal", MaxRetry: 3,
|
||||
}, tradesignal.NewConverter(nil),
|
||||
func(context.Context, string) (*model.Source, error) { return nil, errors.New("not found") },
|
||||
func(context.Context, notify.Request) (notify.Result, error) {
|
||||
t.Fatal("process")
|
||||
return notify.Result{}, nil
|
||||
})
|
||||
if d != DispositionAck {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDisabledSourceAck(t *testing.T) {
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: []byte(`{"action":"OPEN"}`), SourceName: "trade-signal", MaxRetry: 3,
|
||||
}, tradesignal.NewConverter(nil),
|
||||
func(context.Context, string) (*model.Source, error) {
|
||||
return &model.Source{ID: 1, Name: "trade-signal", Status: 0}, nil
|
||||
},
|
||||
func(context.Context, notify.Request) (notify.Result, error) {
|
||||
t.Fatal("process")
|
||||
return notify.Result{}, nil
|
||||
})
|
||||
if d != DispositionAck {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProcessUnprocessableAck(t *testing.T) {
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: []byte(`{"action":"OPEN"}`), SourceName: "trade-signal", MaxRetry: 3,
|
||||
}, tradesignal.NewConverter(nil),
|
||||
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
|
||||
func(context.Context, notify.Request) (notify.Result, error) {
|
||||
return notify.Result{}, notify.ErrUnprocessable
|
||||
})
|
||||
if d != DispositionAck {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProcessErrorRetryThenDLQ(t *testing.T) {
|
||||
process := func(context.Context, notify.Request) (notify.Result, error) {
|
||||
return notify.Result{}, errors.New("db down")
|
||||
}
|
||||
lookup := func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil }
|
||||
conv := tradesignal.NewConverter(nil)
|
||||
body := []byte(`{"action":"OPEN"}`)
|
||||
d := HandleMessage(context.Background(), HandleInput{Body: body, SourceName: "s", MaxRetry: 3}, conv, lookup, process)
|
||||
if d != DispositionRetry {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
d = HandleMessage(context.Background(), HandleInput{
|
||||
Body: body, Headers: map[string]any{"x-retry-count": 3}, SourceName: "s", MaxRetry: 3,
|
||||
}, conv, lookup, process)
|
||||
if d != DispositionDLQ {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSuccessAckPassesEventAndFormatted(t *testing.T) {
|
||||
var got notify.Request
|
||||
d := HandleMessage(context.Background(), HandleInput{
|
||||
Body: []byte(`{"action":"CLOSE","symbol":"ETHUSDT","period":"4h","price":1}`),
|
||||
SourceName: "trade-signal", MaxRetry: 3,
|
||||
}, tradesignal.NewConverter(nil),
|
||||
func(context.Context, string) (*model.Source, error) { return enabledSrc(), nil },
|
||||
func(_ context.Context, req notify.Request) (notify.Result, error) {
|
||||
got = req
|
||||
return notify.Result{Matched: true, Channels: []string{"dingtalk:1"}}, nil
|
||||
})
|
||||
if d != DispositionAck {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
if got.Event != "trade.close" {
|
||||
t.Fatalf("event=%q", got.Event)
|
||||
}
|
||||
if got.Data["period"] != "4h" {
|
||||
t.Fatalf("period=%v", got.Data["period"])
|
||||
}
|
||||
formatted, _ := got.Data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "周期: 4h") {
|
||||
t.Fatalf("formatted=%s", formatted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package subscriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
"aiaa-notification-service/internal/subscriber/tradesignal"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type Subscriber struct {
|
||||
cfg config.SubscriptionConfig
|
||||
conv *tradesignal.Converter
|
||||
lookup SourceLookup
|
||||
process ProcessFunc
|
||||
deduper Deduper
|
||||
}
|
||||
|
||||
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper) (*Subscriber, error) {
|
||||
if cfg.Formatter != "trade_signal" {
|
||||
return nil, fmt.Errorf("unknown formatter %q", cfg.Formatter)
|
||||
}
|
||||
return &Subscriber{
|
||||
cfg: cfg,
|
||||
conv: tradesignal.NewConverter(cfg.StrategyOverrides),
|
||||
lookup: lookup,
|
||||
process: process,
|
||||
deduper: deduper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Subscriber) Run(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := s.consumeOnce(ctx); err != nil {
|
||||
slog.Error("subscriber error, reconnecting", "name", s.cfg.Name, "error", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) consumeOnce(ctx context.Context) error {
|
||||
conn, err := amqp.Dial(s.cfg.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial rabbitmq: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open channel: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if err := ch.Qos(1, 0, false); err != nil {
|
||||
return fmt.Errorf("set qos: %w", err)
|
||||
}
|
||||
if err := s.ensureQueue(ch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deliveries, err := ch.Consume(s.cfg.Queue, s.cfg.Name, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume queue %s: %w", s.cfg.Queue, err)
|
||||
}
|
||||
slog.Info("listening on queue", "name", s.cfg.Name, "queue", s.cfg.Queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery channel closed")
|
||||
}
|
||||
s.handleDelivery(ch, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) ensureQueue(ch *amqp.Channel) error {
|
||||
if s.cfg.Exchange != "" {
|
||||
if err := ch.ExchangeDeclare(s.cfg.Exchange, s.cfg.ExchangeType, true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("declare exchange %q: %w", s.cfg.Exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ch.QueueDeclare(s.cfg.Queue, true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("declare queue %q: %w", s.cfg.Queue, err)
|
||||
}
|
||||
|
||||
if s.cfg.Exchange != "" {
|
||||
if err := ch.QueueBind(s.cfg.Queue, s.cfg.RoutingKey, s.cfg.Exchange, false, nil); err != nil {
|
||||
return fmt.Errorf("bind queue %q to exchange %q: %w", s.cfg.Queue, s.cfg.Exchange, err)
|
||||
}
|
||||
slog.Info("queue bound", "queue", s.cfg.Queue, "exchange", s.cfg.Exchange, "type", s.cfg.ExchangeType, "routing_key", s.cfg.RoutingKey)
|
||||
}
|
||||
|
||||
if s.cfg.DeadLetterQueue != "" {
|
||||
if _, err := ch.QueueDeclare(s.cfg.DeadLetterQueue, true, false, false, false, nil); err != nil {
|
||||
slog.Warn("declare dead letter queue failed", "queue", s.cfg.DeadLetterQueue, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Subscriber) handleDelivery(ch *amqp.Channel, d amqp.Delivery) {
|
||||
disp := HandleMessage(context.Background(), HandleInput{
|
||||
Body: d.Body,
|
||||
Headers: map[string]any(d.Headers),
|
||||
SourceName: s.cfg.Source,
|
||||
MaxRetry: s.cfg.MaxRetry,
|
||||
Deduper: s.deduper,
|
||||
}, s.conv, s.lookup, s.process)
|
||||
|
||||
switch disp {
|
||||
case DispositionRetry:
|
||||
s.republish(ch, d, s.cfg.Queue)
|
||||
case DispositionDLQ:
|
||||
if s.cfg.DeadLetterQueue == "" {
|
||||
slog.Warn("max retry reached, discarded", "name", s.cfg.Name)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
s.republish(ch, d, s.cfg.DeadLetterQueue)
|
||||
default:
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) republish(ch *amqp.Channel, d amqp.Delivery, queue string) {
|
||||
headers := copyAMQPHeaders(d.Headers)
|
||||
headers[retryHeader] = RetryCount(map[string]any(d.Headers)) + 1
|
||||
if err := publishToQueue(ch, queue, d.Body, headers); err != nil {
|
||||
slog.Error("requeue failed", "queue", queue, "error", err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
_ = d.Ack(false)
|
||||
if queue == s.cfg.DeadLetterQueue {
|
||||
slog.Warn("message moved to dlq", "name", s.cfg.Name, "retries", s.cfg.MaxRetry)
|
||||
return
|
||||
}
|
||||
slog.Info("message requeued", "name", s.cfg.Name, "retry", headers[retryHeader], "max", s.cfg.MaxRetry)
|
||||
}
|
||||
|
||||
func copyAMQPHeaders(headers amqp.Table) amqp.Table {
|
||||
out := amqp.Table{}
|
||||
for k, v := range headers {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func publishToQueue(ch *amqp.Channel, queue string, body []byte, headers amqp.Table) error {
|
||||
return ch.Publish(
|
||||
"",
|
||||
queue,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Headers: headers,
|
||||
Body: body,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
var ErrInvalidSignal = errors.New("invalid signal")
|
||||
|
||||
type Converter struct {
|
||||
overrides map[string]config.StrategyOverride
|
||||
positions *Tracker
|
||||
}
|
||||
|
||||
func NewConverter(overrides map[string]config.StrategyOverride) *Converter {
|
||||
return &Converter{
|
||||
overrides: overrides,
|
||||
positions: NewTracker(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error) {
|
||||
var sig Signal
|
||||
if err := json.Unmarshal(body, &sig); err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrInvalidSignal, err)
|
||||
}
|
||||
if strings.TrimSpace(sig.Action) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
}
|
||||
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
|
||||
snap := c.positions.Apply(out)
|
||||
var opts FormatOptions
|
||||
if snap.HasAvg {
|
||||
avg := snap.AvgPrice
|
||||
opts.AvgPrice = &avg
|
||||
}
|
||||
text := Format(out, opts)
|
||||
data, err := toData(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
data["formatted"] = text
|
||||
if snap.HasAvg {
|
||||
data["avgPrice"] = snap.AvgPrice
|
||||
}
|
||||
return "trade." + strings.ToLower(out.Action), data, nil
|
||||
}
|
||||
|
||||
func (c *Converter) overrideFor(code string) *config.StrategyOverride {
|
||||
if c == nil || len(c.overrides) == 0 || code == "" {
|
||||
return nil
|
||||
}
|
||||
override, ok := c.overrides[code]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &override
|
||||
}
|
||||
|
||||
func toData(sig *Signal) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
func TestConvertOpen(t *testing.T) {
|
||||
lev := 100
|
||||
c := NewConverter(map[string]config.StrategyOverride{
|
||||
"BLONG": {QuantityMultipliers: config.QuantityMultipliers{Open: 100}, Leverage: &lev},
|
||||
})
|
||||
event, data, err := c.Convert([]byte(`{
|
||||
"signalId":"s1","strategyCode":"BLONG","symbol":"BTCUSDT",
|
||||
"side":"LONG","action":"OPEN","quantity":0.01,"price":64000,
|
||||
"leverage":10,"period":"1h","eventTime":"2026-06-23T01:30:00Z"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.open" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "周期: 1h") || !strings.Contains(formatted, "开仓数量: 1.00") {
|
||||
t.Fatalf("formatted=\n%s", formatted)
|
||||
}
|
||||
if data["period"] != "1h" || data["strategyCode"] != "BLONG" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if data["leverage"] != float64(100) && data["leverage"] != 100 {
|
||||
t.Fatalf("leverage=%v", data["leverage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMissingAction(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{"symbol":"BTCUSDT"}`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FormatOptions struct {
|
||||
AvgPrice *float64
|
||||
}
|
||||
|
||||
func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
var opt FormatOptions
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
|
||||
title := actionTitle(signal.Side, signal.Action)
|
||||
symbol := trimQuote(signal.Symbol)
|
||||
|
||||
lines := []string{title}
|
||||
lines = append(lines, fmt.Sprintf("交易品种: %s", symbol))
|
||||
if p := strings.TrimSpace(signal.Period); p != "" {
|
||||
lines = append(lines, fmt.Sprintf("周期: %s", p))
|
||||
}
|
||||
|
||||
action := strings.ToUpper(signal.Action)
|
||||
switch action {
|
||||
case "OPEN":
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("OPEN", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.Leverage > 0 {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
if signal.TakeProfitPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %.2f", *signal.TakeProfitPrice))
|
||||
}
|
||||
if signal.StopLossPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %.2f", *signal.StopLossPrice))
|
||||
}
|
||||
case "CLOSE":
|
||||
lines = append(lines, fmt.Sprintf("平仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, closeSizeLine(signal.Quantity, signal.PosMarginRatio))
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("平仓盈亏: %.2f", *signal.PnL))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
}
|
||||
case "ADD":
|
||||
lines = append(lines, fmt.Sprintf("加仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("ADD", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.Leverage > 0 {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
case "REDUCE":
|
||||
lines = append(lines, fmt.Sprintf("减仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("REDUCE", signal.Quantity, signal.PosMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("减仓盈亏: %.2f", *signal.PnL))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
}
|
||||
default:
|
||||
lines = append(lines, fmt.Sprintf("价格: %.2f", signal.Price))
|
||||
if line := sizeLine("", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
}
|
||||
|
||||
if signal.StrategyCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("策略: %s", signal.StrategyCode))
|
||||
}
|
||||
|
||||
eventTime := signal.ParsedEventTime().In(time.Local)
|
||||
lines = append(lines, fmt.Sprintf("Time: %s", eventTime.Format("2006.01.02 15:04:05")))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func actionTitle(side, action string) string {
|
||||
side = strings.ToUpper(side)
|
||||
action = strings.ToUpper(action)
|
||||
|
||||
var pos string
|
||||
switch side {
|
||||
case "LONG":
|
||||
pos = "多单"
|
||||
case "SHORT":
|
||||
pos = "空单"
|
||||
default:
|
||||
pos = side
|
||||
}
|
||||
|
||||
var act string
|
||||
switch action {
|
||||
case "OPEN":
|
||||
act = "开仓"
|
||||
case "ADD":
|
||||
act = "加仓"
|
||||
case "CLOSE":
|
||||
act = "平仓"
|
||||
case "REDUCE":
|
||||
act = "减仓"
|
||||
default:
|
||||
act = action
|
||||
}
|
||||
|
||||
return pos + act
|
||||
}
|
||||
|
||||
func appendAvgPrice(lines []string, avgPrice *float64) []string {
|
||||
if avgPrice == nil || *avgPrice <= 0 {
|
||||
return lines
|
||||
}
|
||||
return append(lines, fmt.Sprintf("平均单价: %.2f", *avgPrice))
|
||||
}
|
||||
|
||||
func closeSizeLine(quantity, posMarginRatio *float64) string {
|
||||
if quantity != nil && *quantity > 0 {
|
||||
return fmt.Sprintf("平仓数量: %.2f", *quantity)
|
||||
}
|
||||
ratio := 1.0
|
||||
if posMarginRatio != nil {
|
||||
ratio = *posMarginRatio
|
||||
}
|
||||
return fmt.Sprintf("平仓比例: %s", formatPercent(ratio))
|
||||
}
|
||||
|
||||
func sizeLine(action string, quantity, marginRatio *float64) string {
|
||||
if quantity != nil && *quantity > 0 {
|
||||
return fmt.Sprintf("%s: %.2f", quantityLabel(action), *quantity)
|
||||
}
|
||||
if marginRatio != nil {
|
||||
return fmt.Sprintf("%s: %s", ratioLabel(action), formatPercent(*marginRatio))
|
||||
}
|
||||
if quantity != nil {
|
||||
return fmt.Sprintf("%s: %.2f", quantityLabel(action), *quantity)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func quantityLabel(action string) string {
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
return "开仓数量"
|
||||
case "ADD":
|
||||
return "加仓数量"
|
||||
case "CLOSE":
|
||||
return "平仓数量"
|
||||
case "REDUCE":
|
||||
return "减仓数量"
|
||||
default:
|
||||
return "数量"
|
||||
}
|
||||
}
|
||||
|
||||
func ratioLabel(action string) string {
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
return "开仓比例"
|
||||
case "ADD":
|
||||
return "加仓比例"
|
||||
case "CLOSE":
|
||||
return "平仓比例"
|
||||
case "REDUCE":
|
||||
return "减仓比例"
|
||||
default:
|
||||
return "仓位比例"
|
||||
}
|
||||
}
|
||||
|
||||
func formatPercent(ratio float64) string {
|
||||
return fmt.Sprintf("%.2f%%", ratio*100)
|
||||
}
|
||||
|
||||
func trimQuote(symbol string) string {
|
||||
symbol = strings.ToUpper(symbol)
|
||||
for _, suffix := range []string{"USDT", "USDC", "BUSD", "USD"} {
|
||||
if strings.HasSuffix(symbol, suffix) && len(symbol) > len(suffix) {
|
||||
return symbol[:len(symbol)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr(v float64) *float64 { return &v }
|
||||
|
||||
func TestFormatOpenIncludesPeriodAfterSymbol(t *testing.T) {
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
|
||||
Quantity: ptr(0.01), Price: 64000.5, Leverage: 10,
|
||||
Period: "1h", EventTime: "2026-06-23T01:30:00Z",
|
||||
})
|
||||
if !strings.Contains(out, "多单开仓") || !strings.Contains(out, "交易品种: BTC") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
idxSym := strings.Index(out, "交易品种: BTC")
|
||||
idxPer := strings.Index(out, "周期: 1h")
|
||||
idxPx := strings.Index(out, "开仓价格:")
|
||||
if idxPer < 0 || idxPer < idxSym || idxPx < idxPer {
|
||||
t.Fatalf("period placement:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOmitsEmptyPeriod(t *testing.T) {
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
|
||||
Price: 1, EventTime: "2026-06-23T01:30:00Z",
|
||||
})
|
||||
if strings.Contains(out, "周期:") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCloseLong(t *testing.T) {
|
||||
pnl, bal := 941.0, 74744.90
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "CLOSE",
|
||||
Quantity: ptr(3), Price: 63175.76,
|
||||
EventTime: "2026-07-07T05:52:14Z", PnL: &pnl, AccountBalance: &bal,
|
||||
})
|
||||
for _, want := range []string{"多单平仓", "平仓价格: 63175.76", "平仓盈亏: 941.00"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWithAvgPrice(t *testing.T) {
|
||||
avg := 150.0
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "ADD",
|
||||
Quantity: ptr(1), Price: 200, EventTime: "2026-07-07T05:52:14Z",
|
||||
}, FormatOptions{AvgPrice: &avg})
|
||||
if !strings.Contains(out, "平均单价: 150.00") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tradesignal
|
||||
|
||||
import "aiaa-notification-service/internal/config"
|
||||
|
||||
func Apply(signal *Signal, override *config.StrategyOverride) *Signal {
|
||||
if override == nil {
|
||||
return signal
|
||||
}
|
||||
|
||||
out := *signal
|
||||
if signal.Quantity != nil {
|
||||
q := *signal.Quantity
|
||||
out.Quantity = &q
|
||||
}
|
||||
|
||||
if out.Quantity != nil && *out.Quantity > 0 {
|
||||
multiplier := override.QuantityMultiplierFor(out.Action)
|
||||
if multiplier != 1 {
|
||||
q := *out.Quantity * multiplier
|
||||
out.Quantity = &q
|
||||
}
|
||||
}
|
||||
|
||||
if override.Leverage != nil && *override.Leverage > 0 {
|
||||
out.Leverage = *override.Leverage
|
||||
}
|
||||
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNone mode = iota
|
||||
modeQty
|
||||
modeWeight
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
AvgPrice float64
|
||||
Size float64
|
||||
HasAvg bool
|
||||
}
|
||||
|
||||
type state struct {
|
||||
avg float64
|
||||
size float64
|
||||
mode mode
|
||||
}
|
||||
|
||||
type Tracker struct {
|
||||
mu sync.Mutex
|
||||
positions map[string]*state
|
||||
applied map[string]Snapshot
|
||||
}
|
||||
|
||||
func NewTracker() *Tracker {
|
||||
return &Tracker{
|
||||
positions: make(map[string]*state),
|
||||
applied: make(map[string]Snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) Apply(signal *Signal) Snapshot {
|
||||
if signal == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if signal.SignalID != "" {
|
||||
if snap, ok := t.applied[signal.SignalID]; ok {
|
||||
return snap
|
||||
}
|
||||
}
|
||||
|
||||
key := positionKey(signal.StrategyCode, signal.Symbol, signal.Side)
|
||||
action := strings.ToUpper(signal.Action)
|
||||
st := t.positions[key]
|
||||
|
||||
var snap Snapshot
|
||||
switch action {
|
||||
case "OPEN":
|
||||
st = openPosition(signal)
|
||||
snap = snapshotFrom(st)
|
||||
if st != nil {
|
||||
t.positions[key] = st
|
||||
} else {
|
||||
delete(t.positions, key)
|
||||
}
|
||||
case "ADD":
|
||||
st = addPosition(st, signal)
|
||||
snap = snapshotFrom(st)
|
||||
if st != nil {
|
||||
t.positions[key] = st
|
||||
}
|
||||
case "REDUCE":
|
||||
snap = snapshotFrom(st)
|
||||
st = reducePosition(st, signal)
|
||||
if st == nil || st.size <= 0 {
|
||||
delete(t.positions, key)
|
||||
} else {
|
||||
t.positions[key] = st
|
||||
}
|
||||
case "CLOSE":
|
||||
snap = snapshotFrom(st)
|
||||
delete(t.positions, key)
|
||||
default:
|
||||
snap = snapshotFrom(st)
|
||||
}
|
||||
|
||||
if signal.SignalID != "" {
|
||||
t.applied[signal.SignalID] = snap
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func openPosition(signal *Signal) *state {
|
||||
if qty, ok := positiveQty(signal.Quantity); ok {
|
||||
return &state{avg: signal.Price, size: qty, mode: modeQty}
|
||||
}
|
||||
if w, ok := positiveRatio(signal.AmountMarginRatio); ok {
|
||||
return &state{avg: signal.Price, size: w, mode: modeWeight}
|
||||
}
|
||||
if signal.Price > 0 {
|
||||
return &state{avg: signal.Price, size: 0, mode: modeNone}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPosition(st *state, signal *Signal) *state {
|
||||
if st == nil || st.size <= 0 {
|
||||
return openPosition(signal)
|
||||
}
|
||||
|
||||
if qty, ok := positiveQty(signal.Quantity); ok {
|
||||
if st.mode == modeWeight {
|
||||
return st
|
||||
}
|
||||
if st.mode == modeNone || st.size == 0 {
|
||||
st.mode = modeQty
|
||||
st.size = qty
|
||||
st.avg = signal.Price
|
||||
return st
|
||||
}
|
||||
st.avg = (st.size*st.avg + qty*signal.Price) / (st.size + qty)
|
||||
st.size += qty
|
||||
st.mode = modeQty
|
||||
return st
|
||||
}
|
||||
|
||||
if w, ok := positiveRatio(signal.AmountMarginRatio); ok {
|
||||
if st.mode == modeQty {
|
||||
return st
|
||||
}
|
||||
if st.mode == modeNone || st.size == 0 {
|
||||
st.mode = modeWeight
|
||||
st.size = w
|
||||
st.avg = signal.Price
|
||||
return st
|
||||
}
|
||||
st.avg = (st.size*st.avg + w*signal.Price) / (st.size + w)
|
||||
st.size += w
|
||||
st.mode = modeWeight
|
||||
return st
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func reducePosition(st *state, signal *Signal) *state {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if qty, ok := positiveQty(signal.Quantity); ok && st.mode == modeQty {
|
||||
st.size -= qty
|
||||
if st.size < 0 {
|
||||
st.size = 0
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
ratio := 0.0
|
||||
if r, ok := positiveRatio(signal.PosMarginRatio); ok {
|
||||
ratio = r
|
||||
} else if signal.Quantity == nil && signal.PosMarginRatio == nil {
|
||||
return st
|
||||
}
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
if ratio > 0 {
|
||||
st.size *= (1 - ratio)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func snapshotFrom(st *state) Snapshot {
|
||||
if st == nil || st.avg <= 0 {
|
||||
return Snapshot{}
|
||||
}
|
||||
return Snapshot{
|
||||
AvgPrice: st.avg,
|
||||
Size: st.size,
|
||||
HasAvg: true,
|
||||
}
|
||||
}
|
||||
|
||||
func positiveQty(q *float64) (float64, bool) {
|
||||
if q == nil || *q <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return *q, true
|
||||
}
|
||||
|
||||
func positiveRatio(r *float64) (float64, bool) {
|
||||
if r == nil || *r <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return *r, true
|
||||
}
|
||||
|
||||
func positionKey(strategyCode, symbol, side string) string {
|
||||
return strings.ToUpper(strategyCode) + "|" + strings.ToUpper(symbol) + "|" + strings.ToUpper(side)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tradesignal
|
||||
|
||||
import "time"
|
||||
|
||||
type Signal struct {
|
||||
SignalID string `json:"signalId"`
|
||||
SourcePosID string `json:"sourcePosId"`
|
||||
StrategyCode string `json:"strategyCode"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Action string `json:"action"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
AmountMarginRatio *float64 `json:"amountMarginRatio"`
|
||||
PosMarginRatio *float64 `json:"posMarginRatio"`
|
||||
Price float64 `json:"price"`
|
||||
Leverage int `json:"leverage"`
|
||||
Period string `json:"period"`
|
||||
EventTime string `json:"eventTime"`
|
||||
TakeProfitPrice *float64 `json:"takeProfitPrice"`
|
||||
StopLossPrice *float64 `json:"stopLossPrice"`
|
||||
TakeProfitRatio *float64 `json:"takeProfitRatio"`
|
||||
StopLossRatio *float64 `json:"stopLossRatio"`
|
||||
PnL *float64 `json:"pnl"`
|
||||
AccountBalance *float64 `json:"accountBalance"`
|
||||
}
|
||||
|
||||
func (s *Signal) ParsedEventTime() time.Time {
|
||||
if s.EventTime == "" {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s.EventTime)
|
||||
if err != nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
func TestApplyQuantityMultiplierAndLeverage(t *testing.T) {
|
||||
leverage := 20
|
||||
override := &config.StrategyOverride{
|
||||
QuantityMultipliers: config.QuantityMultipliers{
|
||||
Open: 2,
|
||||
Add: 1.5,
|
||||
Reduce: 0.5,
|
||||
Close: 3,
|
||||
},
|
||||
Leverage: &leverage,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
action string
|
||||
quantity float64
|
||||
wantQty float64
|
||||
}{
|
||||
{"OPEN", 1, 2},
|
||||
{"ADD", 2, 3},
|
||||
{"REDUCE", 4, 2},
|
||||
{"CLOSE", 1, 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
signal := &Signal{
|
||||
Action: tt.action,
|
||||
Quantity: ptr(tt.quantity),
|
||||
Leverage: 10,
|
||||
}
|
||||
out := Apply(signal, override)
|
||||
if out.Quantity == nil || *out.Quantity != tt.wantQty {
|
||||
t.Fatalf("action=%s quantity=%v want %v", tt.action, out.Quantity, tt.wantQty)
|
||||
}
|
||||
if out.Leverage != 20 {
|
||||
t.Fatalf("action=%s leverage=%d want 20", tt.action, out.Leverage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsOriginalWhenNoOverride(t *testing.T) {
|
||||
signal := &Signal{
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(1.5),
|
||||
Leverage: 8,
|
||||
}
|
||||
out := Apply(signal, nil)
|
||||
if out != signal {
|
||||
t.Fatalf("expected same signal pointer when override is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDoesNotChangeMarginRatioOnlySignals(t *testing.T) {
|
||||
marginRatio := 0.2
|
||||
signal := &Signal{
|
||||
Action: "OPEN",
|
||||
AmountMarginRatio: &marginRatio,
|
||||
Leverage: 5,
|
||||
}
|
||||
override := &config.StrategyOverride{
|
||||
QuantityMultipliers: config.QuantityMultipliers{Open: 2},
|
||||
}
|
||||
out := Apply(signal, override)
|
||||
if out.Quantity != nil {
|
||||
t.Fatalf("expected quantity unchanged when only margin ratio is set")
|
||||
}
|
||||
if out.Leverage != 5 {
|
||||
t.Fatalf("expected leverage unchanged, got %d", out.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvgPriceOpenAndAdd(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
|
||||
open := &Signal{
|
||||
SignalID: "s1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(2),
|
||||
Price: 100,
|
||||
}
|
||||
snap := tr.Apply(open)
|
||||
if !snap.HasAvg || snap.AvgPrice != 100 {
|
||||
t.Fatalf("open avg=%v has=%v", snap.AvgPrice, snap.HasAvg)
|
||||
}
|
||||
|
||||
add := &Signal{
|
||||
SignalID: "s2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "ADD",
|
||||
Quantity: ptr(2),
|
||||
Price: 200,
|
||||
}
|
||||
snap = tr.Apply(add)
|
||||
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("expected avg 150, got %v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvgPriceWithMarginRatio(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
|
||||
open := &Signal{
|
||||
SignalID: "r1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "ETHUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
AmountMarginRatio: ptr(0.1),
|
||||
Price: 100,
|
||||
}
|
||||
tr.Apply(open)
|
||||
|
||||
add := &Signal{
|
||||
SignalID: "r2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "ETHUSDT",
|
||||
Side: "LONG",
|
||||
Action: "ADD",
|
||||
AmountMarginRatio: ptr(0.1),
|
||||
Price: 200,
|
||||
}
|
||||
snap := tr.Apply(add)
|
||||
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("expected weighted avg 150, got %v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
tr.Apply(&Signal{
|
||||
SignalID: "c1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(1),
|
||||
Price: 64000,
|
||||
})
|
||||
|
||||
snap := tr.Apply(&Signal{
|
||||
SignalID: "c2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "CLOSE",
|
||||
Price: 65000,
|
||||
})
|
||||
if !snap.HasAvg || snap.AvgPrice != 64000 {
|
||||
t.Fatalf("close should report entry avg 64000, got %v", snap.AvgPrice)
|
||||
}
|
||||
|
||||
snap = tr.Apply(&Signal{
|
||||
SignalID: "c3",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "ADD",
|
||||
Quantity: ptr(1),
|
||||
Price: 70000,
|
||||
})
|
||||
if !snap.HasAvg || snap.AvgPrice != 70000 {
|
||||
t.Fatalf("after close, add should reopen at 70000, got %v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalIDIdempotent(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
sig := &Signal{
|
||||
SignalID: "dup",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(1),
|
||||
Price: 100,
|
||||
}
|
||||
tr.Apply(sig)
|
||||
tr.Apply(sig)
|
||||
|
||||
snap := tr.Apply(&Signal{
|
||||
SignalID: "dup2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "ADD",
|
||||
Quantity: ptr(1),
|
||||
Price: 200,
|
||||
})
|
||||
if math.Abs(snap.AvgPrice-150) > 1e-9 {
|
||||
t.Fatalf("duplicate open should not double size, avg=%v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSideIsolated(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
tr.Apply(&Signal{
|
||||
SignalID: "l1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
||||
})
|
||||
snap := tr.Apply(&Signal{
|
||||
SignalID: "s1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "SHORT",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 200,
|
||||
})
|
||||
if snap.AvgPrice != 200 {
|
||||
t.Fatalf("short should be isolated, got %v", snap.AvgPrice)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user