b396638438
Motivation: 持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。 Changes: * 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化 * 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入 * 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真 * 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖 * 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
230 lines
5.2 KiB
Go
230 lines
5.2 KiB
Go
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 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()
|
|
|
|
open := &Signal{
|
|
SignalID: "s1",
|
|
StrategyCode: "BLONG",
|
|
Symbol: "BTCUSDT",
|
|
Side: "LONG",
|
|
Action: "OPEN",
|
|
Quantity: ptr(2),
|
|
Price: 100,
|
|
}
|
|
snap := mustApply(t, tr, 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 = mustApply(t, tr, 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,
|
|
}
|
|
mustApply(t, tr, open)
|
|
|
|
add := &Signal{
|
|
SignalID: "r2",
|
|
StrategyCode: "BLONG",
|
|
Symbol: "ETHUSDT",
|
|
Side: "LONG",
|
|
Action: "ADD",
|
|
AmountMarginRatio: ptr(0.1),
|
|
Price: 200,
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestCloseKeepsEntryAvgInSnapshot(t *testing.T) {
|
|
tr := NewTracker()
|
|
mustApply(t, tr, &Signal{
|
|
SignalID: "c1",
|
|
StrategyCode: "BLONG",
|
|
Symbol: "BTCUSDT",
|
|
Side: "LONG",
|
|
Action: "OPEN",
|
|
Quantity: ptr(1),
|
|
Price: 64000,
|
|
})
|
|
|
|
snap := mustApply(t, tr, &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 = mustApply(t, tr, &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,
|
|
}
|
|
mustApply(t, tr, sig)
|
|
mustApply(t, tr, sig)
|
|
|
|
snap := mustApply(t, tr, &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()
|
|
mustApply(t, tr, &Signal{
|
|
SignalID: "l1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
|
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
|
})
|
|
snap := mustApply(t, tr, &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)
|
|
}
|
|
}
|