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