feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确

Motivation:
持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。

Changes:

* 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化
* 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入
* 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真
* 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖
* 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
This commit is contained in:
2026-08-23 14:43:53 +08:00
parent f8e74738cc
commit b396638438
10 changed files with 404 additions and 46 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ func main() {
deduper := subscriber.NewCacheDeduper(redisCache, cfg.SubscriptionDedupTTL)
for _, sub := range cfg.ActiveSubscriptions() {
sub := sub
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper)
cons, err := subscriber.New(sub, lookup, notifySvc.Process, deduper, redisCache)
if err != nil {
slog.Error("subscriber init", "name", sub.Name, "error", err)
os.Exit(1)
+46
View File
@@ -0,0 +1,46 @@
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type KVWrite struct {
Key string
Val []byte
TTL time.Duration
Delete bool
}
func (c *Cache) GetRaw(ctx context.Context, key string) ([]byte, error) {
if c == nil || c.rdb == nil {
return nil, fmt.Errorf("redis unavailable")
}
b, err := c.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil
}
return b, err
}
func (c *Cache) TxWrite(ctx context.Context, writes []KVWrite) error {
if c == nil || c.rdb == nil {
return fmt.Errorf("redis unavailable")
}
if len(writes) == 0 {
return nil
}
pipe := c.rdb.TxPipeline()
for _, w := range writes {
if w.Delete {
pipe.Del(ctx, w.Key)
continue
}
pipe.Set(ctx, w.Key, w.Val, w.TTL)
}
_, err := pipe.Exec(ctx)
return err
}
+5
View File
@@ -8,6 +8,7 @@ import (
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/notify"
"aiaa-notification-service/internal/subscriber/tradesignal"
)
const retryHeader = "x-retry-count"
@@ -82,6 +83,10 @@ func HandleMessage(ctx context.Context, in HandleInput, conv MessageConverter, l
event, data, err := conv.Convert(in.Body)
if err != nil {
hash := MessageHash(in.Body)
if errors.Is(err, tradesignal.ErrPositionStore) {
slog.Warn("position store failed, retry", "hash", hash, "raw", raw, "error", errText(err))
return DecideRetry(RetryCount(in.Headers), in.MaxRetry)
}
slog.Warn("invalid signal, ack", "hash", hash, "raw", raw, "error", errText(err))
return DispositionAck
}
+22
View File
@@ -108,6 +108,28 @@ func TestHandleProcessErrorRetryThenDLQ(t *testing.T) {
}
}
func TestHandlePositionStoreErrorRetries(t *testing.T) {
d := HandleMessage(context.Background(), HandleInput{
Body: []byte(`{"action":"OPEN"}`), SourceName: "s", MaxRetry: 3,
}, stubConverter{err: tradesignal.ErrPositionStore},
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 != DispositionRetry {
t.Fatalf("%v", d)
}
}
type stubConverter struct {
err error
}
func (s stubConverter) Convert([]byte) (string, map[string]interface{}, error) {
return "", nil, s.err
}
func TestHandleSuccessAckPassesEventAndFormatted(t *testing.T) {
var got notify.Request
d := HandleMessage(context.Background(), HandleInput{
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"net/url"
"time"
"aiaa-notification-service/internal/cache"
"aiaa-notification-service/internal/config"
"aiaa-notification-service/internal/subscriber/cryptostrategy"
"aiaa-notification-service/internal/subscriber/tradesignal"
@@ -22,11 +23,11 @@ type Subscriber struct {
deduper Deduper
}
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper) (*Subscriber, error) {
func New(cfg config.SubscriptionConfig, lookup SourceLookup, process ProcessFunc, deduper Deduper, redisCache *cache.Cache) (*Subscriber, error) {
var conv MessageConverter
switch cfg.Formatter {
case "trade_signal":
conv = tradesignal.NewConverter(cfg.StrategyOverrides)
conv = tradesignal.NewConverterWithCache(cfg.StrategyOverrides, redisCache)
case "crypto_strategy":
conv = cryptostrategy.NewConverter()
default:
+11 -2
View File
@@ -6,10 +6,12 @@ import (
"fmt"
"strings"
"aiaa-notification-service/internal/cache"
"aiaa-notification-service/internal/config"
)
var ErrInvalidSignal = errors.New("invalid signal")
var ErrPositionStore = errors.New("position store")
type Converter struct {
overrides map[string]config.StrategyOverride
@@ -17,9 +19,13 @@ type Converter struct {
}
func NewConverter(overrides map[string]config.StrategyOverride) *Converter {
return NewConverterWithCache(overrides, nil)
}
func NewConverterWithCache(overrides map[string]config.StrategyOverride, c *cache.Cache) *Converter {
return &Converter{
overrides: overrides,
positions: NewTracker(),
positions: NewTrackerWithStore(newRedisStore(c)),
}
}
@@ -39,7 +45,10 @@ func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error)
return "trade.message", data, nil
}
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
snap := c.positions.Apply(out)
snap, err := c.positions.Apply(out)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrPositionStore, err)
}
var opts FormatOptions
if snap.HasAvg {
avg := snap.AvgPrice
+33 -29
View File
@@ -14,9 +14,9 @@ const (
)
type Snapshot struct {
AvgPrice float64
Size float64
HasAvg bool
AvgPrice float64 `json:"avgPrice"`
Size float64 `json:"size"`
HasAvg bool `json:"hasAvg"`
}
type state struct {
@@ -26,71 +26,75 @@ type state struct {
}
type Tracker struct {
mu sync.Mutex
positions map[string]*state
applied map[string]Snapshot
mu sync.Mutex
store positionStore
}
func NewTracker() *Tracker {
return &Tracker{
positions: make(map[string]*state),
applied: make(map[string]Snapshot),
}
return NewTrackerWithStore(newMemoryStore())
}
func (t *Tracker) Apply(signal *Signal) Snapshot {
func NewTrackerWithStore(store positionStore) *Tracker {
if store == nil {
store = newMemoryStore()
}
return &Tracker{store: store}
}
func (t *Tracker) Apply(signal *Signal) (Snapshot, error) {
if signal == nil {
return Snapshot{}
return Snapshot{}, nil
}
t.mu.Lock()
defer t.mu.Unlock()
if signal.SignalID != "" {
if snap, ok := t.applied[signal.SignalID]; ok {
return snap
if snap, ok, err := t.store.loadApplied(signal.SignalID); err != nil {
return Snapshot{}, err
} else if ok {
return snap, nil
}
}
key := positionKey(signal.StrategyCode, signal.Symbol, signal.Side)
action := strings.ToUpper(signal.Action)
st := t.positions[key]
st, err := t.store.load(key)
if err != nil {
return Snapshot{}, err
}
var snap Snapshot
del := false
switch action {
case "OPEN":
st = openPosition(signal)
snap = snapshotFrom(st)
if st != nil {
t.positions[key] = st
} else {
delete(t.positions, key)
if st == nil {
del = true
}
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
del = true
st = nil
}
case "CLOSE":
snap = snapshotFrom(st)
delete(t.positions, key)
del = true
st = nil
default:
snap = snapshotFrom(st)
}
if signal.SignalID != "" {
t.applied[signal.SignalID] = snap
if err := t.store.commit(key, st, del, signal.SignalID, snap); err != nil {
return Snapshot{}, err
}
return snap
return snap, nil
}
func openPosition(signal *Signal) *state {
+145
View File
@@ -0,0 +1,145 @@
package tradesignal
import (
"context"
"encoding/json"
"sync"
"time"
"aiaa-notification-service/internal/cache"
)
const (
positionKeyPrefix = "notify:position:"
appliedKeyPrefix = "notify:position:applied:"
positionTTL = 30 * 24 * time.Hour
appliedTTL = 7 * 24 * time.Hour
storeTimeout = 2 * time.Second
)
type persistedState struct {
Avg float64 `json:"avg"`
Size float64 `json:"size"`
Mode int `json:"mode"`
}
type positionStore interface {
load(key string) (*state, error)
commit(key string, st *state, del bool, signalID string, snap Snapshot) error
loadApplied(signalID string) (Snapshot, bool, error)
}
type memoryStore struct {
mu sync.Mutex
positions map[string]*state
applied map[string]Snapshot
}
func newMemoryStore() *memoryStore {
return &memoryStore{
positions: make(map[string]*state),
applied: make(map[string]Snapshot),
}
}
func (m *memoryStore) load(key string) (*state, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.positions[key], nil
}
func (m *memoryStore) loadApplied(signalID string) (Snapshot, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
snap, ok := m.applied[signalID]
return snap, ok, nil
}
func (m *memoryStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
m.mu.Lock()
defer m.mu.Unlock()
if del {
delete(m.positions, key)
} else if st != nil {
m.positions[key] = st
}
if signalID != "" {
m.applied[signalID] = snap
}
return nil
}
type kvClient interface {
GetRaw(ctx context.Context, key string) ([]byte, error)
TxWrite(ctx context.Context, writes []cache.KVWrite) error
}
type redisStore struct {
c kvClient
}
func newRedisStore(c *cache.Cache) positionStore {
if c == nil {
return newMemoryStore()
}
return &redisStore{c: c}
}
func (s *redisStore) ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), storeTimeout)
}
func (s *redisStore) load(key string) (*state, error) {
ctx, cancel := s.ctx()
defer cancel()
b, err := s.c.GetRaw(ctx, positionKeyPrefix+key)
if err != nil || len(b) == 0 {
return nil, err
}
var p persistedState
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
return &state{avg: p.Avg, size: p.Size, mode: mode(p.Mode)}, nil
}
func (s *redisStore) loadApplied(signalID string) (Snapshot, bool, error) {
if signalID == "" {
return Snapshot{}, false, nil
}
ctx, cancel := s.ctx()
defer cancel()
b, err := s.c.GetRaw(ctx, appliedKeyPrefix+signalID)
if err != nil || len(b) == 0 {
return Snapshot{}, false, err
}
var snap Snapshot
if err := json.Unmarshal(b, &snap); err != nil {
return Snapshot{}, false, err
}
return snap, true, nil
}
func (s *redisStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
ctx, cancel := s.ctx()
defer cancel()
writes := make([]cache.KVWrite, 0, 2)
posKey := positionKeyPrefix + key
if del {
writes = append(writes, cache.KVWrite{Key: posKey, Delete: true})
} else if st != nil {
b, err := json.Marshal(persistedState{Avg: st.avg, Size: st.size, Mode: int(st.mode)})
if err != nil {
return err
}
writes = append(writes, cache.KVWrite{Key: posKey, Val: b, TTL: positionTTL})
}
if signalID != "" {
b, err := json.Marshal(snap)
if err != nil {
return err
}
writes = append(writes, cache.KVWrite{Key: appliedKeyPrefix + signalID, Val: b, TTL: appliedTTL})
}
return s.c.TxWrite(ctx, writes)
}
@@ -0,0 +1,117 @@
package tradesignal
import (
"context"
"errors"
"math"
"sync"
"testing"
"aiaa-notification-service/internal/cache"
)
type fakeKV struct {
mu sync.Mutex
data map[string][]byte
fail bool
}
func newFakeKV() *fakeKV {
return &fakeKV{data: make(map[string][]byte)}
}
func (f *fakeKV) GetRaw(_ context.Context, key string) ([]byte, error) {
if f.fail {
return nil, errors.New("redis down")
}
f.mu.Lock()
defer f.mu.Unlock()
if b, ok := f.data[key]; ok {
return append([]byte(nil), b...), nil
}
return nil, nil
}
func (f *fakeKV) TxWrite(_ context.Context, writes []cache.KVWrite) error {
if f.fail {
return errors.New("redis down")
}
f.mu.Lock()
defer f.mu.Unlock()
for _, w := range writes {
if w.Delete {
delete(f.data, w.Key)
continue
}
f.data[w.Key] = append([]byte(nil), w.Val...)
}
return nil
}
func TestTrackerPersistsAcrossMemoryInstances(t *testing.T) {
store := newMemoryStore()
mustApply(t, NewTrackerWithStore(store), &Signal{
SignalID: "p1", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
Action: "OPEN", Quantity: ptr(10), Price: 0.00000059,
})
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
SignalID: "p2", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
Action: "ADD", Quantity: ptr(10), Price: 0.00000061,
})
if math.Abs(snap.AvgPrice-0.0000006) > 1e-12 {
t.Fatalf("shared memory store avg=%v", snap.AvgPrice)
}
}
func TestTrackerPersistsAcrossRedisInstances(t *testing.T) {
store := &redisStore{c: newFakeKV()}
mustApply(t, NewTrackerWithStore(store), &Signal{
SignalID: "r1", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
Action: "OPEN", Quantity: ptr(10), Price: 100,
})
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
SignalID: "r2", StrategyCode: "BLONG", Symbol: "PEPEUSDT", Side: "SHORT",
Action: "ADD", Quantity: ptr(10), Price: 200,
})
if math.Abs(snap.AvgPrice-150) > 1e-9 {
t.Fatalf("shared redis store avg=%v", snap.AvgPrice)
}
}
func TestTrackerRedisIdempotentAcrossInstances(t *testing.T) {
store := &redisStore{c: newFakeKV()}
open := &Signal{
SignalID: "same", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
Action: "OPEN", Quantity: ptr(1), Price: 100,
}
mustApply(t, NewTrackerWithStore(store), open)
mustApply(t, NewTrackerWithStore(store), open)
snap := mustApply(t, NewTrackerWithStore(store), &Signal{
SignalID: "add", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
Action: "ADD", Quantity: ptr(1), Price: 200,
})
if math.Abs(snap.AvgPrice-150) > 1e-9 {
t.Fatalf("replayed open should not double size, avg=%v", snap.AvgPrice)
}
}
func TestTrackerRedisStoreError(t *testing.T) {
tr := NewTrackerWithStore(&redisStore{c: &fakeKV{fail: true, data: map[string][]byte{}}})
_, err := tr.Apply(&Signal{
SignalID: "e1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
Action: "OPEN", Quantity: ptr(1), Price: 100,
})
if err == nil {
t.Fatal("expected store error")
}
}
func TestConvertPositionStoreError(t *testing.T) {
c := &Converter{
positions: NewTrackerWithStore(&redisStore{c: &fakeKV{fail: true, data: map[string][]byte{}}}),
}
_, _, err := c.Convert([]byte(`{"action":"OPEN","symbol":"BTCUSDT","price":1,"quantity":1}`))
if !errors.Is(err, ErrPositionStore) {
t.Fatalf("err=%v", err)
}
}
@@ -77,6 +77,15 @@ func TestApplyDoesNotChangeMarginRatioOnlySignals(t *testing.T) {
}
}
func mustApply(t *testing.T, tr *Tracker, signal *Signal) Snapshot {
t.Helper()
snap, err := tr.Apply(signal)
if err != nil {
t.Fatal(err)
}
return snap
}
func TestAvgPriceOpenAndAdd(t *testing.T) {
tr := NewTracker()
@@ -89,7 +98,7 @@ func TestAvgPriceOpenAndAdd(t *testing.T) {
Quantity: ptr(2),
Price: 100,
}
snap := tr.Apply(open)
snap := mustApply(t, tr, open)
if !snap.HasAvg || snap.AvgPrice != 100 {
t.Fatalf("open avg=%v has=%v", snap.AvgPrice, snap.HasAvg)
}
@@ -103,7 +112,7 @@ func TestAvgPriceOpenAndAdd(t *testing.T) {
Quantity: ptr(2),
Price: 200,
}
snap = tr.Apply(add)
snap = mustApply(t, tr, add)
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
t.Fatalf("expected avg 150, got %v", snap.AvgPrice)
}
@@ -121,7 +130,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
AmountMarginRatio: ptr(0.1),
Price: 100,
}
tr.Apply(open)
mustApply(t, tr, open)
add := &Signal{
SignalID: "r2",
@@ -132,7 +141,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
AmountMarginRatio: ptr(0.1),
Price: 200,
}
snap := tr.Apply(add)
snap := mustApply(t, tr, add)
if !snap.HasAvg || math.Abs(snap.AvgPrice-150) > 1e-9 {
t.Fatalf("expected weighted avg 150, got %v", snap.AvgPrice)
}
@@ -140,7 +149,7 @@ func TestAvgPriceWithMarginRatio(t *testing.T) {
func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
tr := NewTracker()
tr.Apply(&Signal{
mustApply(t, tr, &Signal{
SignalID: "c1",
StrategyCode: "BLONG",
Symbol: "BTCUSDT",
@@ -150,7 +159,7 @@ func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
Price: 64000,
})
snap := tr.Apply(&Signal{
snap := mustApply(t, tr, &Signal{
SignalID: "c2",
StrategyCode: "BLONG",
Symbol: "BTCUSDT",
@@ -162,7 +171,7 @@ func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
t.Fatalf("close should report entry avg 64000, got %v", snap.AvgPrice)
}
snap = tr.Apply(&Signal{
snap = mustApply(t, tr, &Signal{
SignalID: "c3",
StrategyCode: "BLONG",
Symbol: "BTCUSDT",
@@ -187,10 +196,10 @@ func TestSignalIDIdempotent(t *testing.T) {
Quantity: ptr(1),
Price: 100,
}
tr.Apply(sig)
tr.Apply(sig)
mustApply(t, tr, sig)
mustApply(t, tr, sig)
snap := tr.Apply(&Signal{
snap := mustApply(t, tr, &Signal{
SignalID: "dup2",
StrategyCode: "BLONG",
Symbol: "BTCUSDT",
@@ -206,11 +215,11 @@ func TestSignalIDIdempotent(t *testing.T) {
func TestDifferentSideIsolated(t *testing.T) {
tr := NewTracker()
tr.Apply(&Signal{
mustApply(t, tr, &Signal{
SignalID: "l1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
Action: "OPEN", Quantity: ptr(1), Price: 100,
})
snap := tr.Apply(&Signal{
snap := mustApply(t, tr, &Signal{
SignalID: "s1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "SHORT",
Action: "OPEN", Quantity: ptr(1), Price: 200,
})