feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation: 持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。 Changes: * 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化 * 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入 * 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真 * 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖 * 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user