Files
aiaa-notification-server/internal/subscriber/tradesignal/store_test.go
T
ryan b396638438 feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation:
持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。

Changes:

* 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化
* 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入
* 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真
* 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖
* 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
2026-08-23 14:43:53 +08:00

118 lines
3.3 KiB
Go

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)
}
}