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:
2026-08-15 17:34:49 +08:00
parent 1f4fe2fb75
commit 6f846a0a3c
25 changed files with 3129 additions and 114 deletions
+70
View File
@@ -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)
}
+89
View File
@@ -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())
}
}
+117
View File
@@ -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
}
+135
View File
@@ -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)
}
}
+182
View File
@@ -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)
}
}
+198
View File
@@ -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
}
+204
View File
@@ -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)
}
+36
View File
@@ -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)
}
}