feat: subscribe to RabbitMQ trade signals and notify by rules
Consume configurable queues, format signals (including period), share NotifyService with HTTP, and drop duplicate bodies within 1h.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
var ErrInvalidSignal = errors.New("invalid signal")
|
||||
|
||||
type Converter struct {
|
||||
overrides map[string]config.StrategyOverride
|
||||
positions *Tracker
|
||||
}
|
||||
|
||||
func NewConverter(overrides map[string]config.StrategyOverride) *Converter {
|
||||
return &Converter{
|
||||
overrides: overrides,
|
||||
positions: NewTracker(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Converter) Convert(body []byte) (string, map[string]interface{}, error) {
|
||||
var sig Signal
|
||||
if err := json.Unmarshal(body, &sig); err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %v", ErrInvalidSignal, err)
|
||||
}
|
||||
if strings.TrimSpace(sig.Action) == "" {
|
||||
return "", nil, fmt.Errorf("%w: missing action", ErrInvalidSignal)
|
||||
}
|
||||
out := Apply(&sig, c.overrideFor(sig.StrategyCode))
|
||||
snap := c.positions.Apply(out)
|
||||
var opts FormatOptions
|
||||
if snap.HasAvg {
|
||||
avg := snap.AvgPrice
|
||||
opts.AvgPrice = &avg
|
||||
}
|
||||
text := Format(out, opts)
|
||||
data, err := toData(out)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
data["formatted"] = text
|
||||
if snap.HasAvg {
|
||||
data["avgPrice"] = snap.AvgPrice
|
||||
}
|
||||
return "trade." + strings.ToLower(out.Action), data, nil
|
||||
}
|
||||
|
||||
func (c *Converter) overrideFor(code string) *config.StrategyOverride {
|
||||
if c == nil || len(c.overrides) == 0 || code == "" {
|
||||
return nil
|
||||
}
|
||||
override, ok := c.overrides[code]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &override
|
||||
}
|
||||
|
||||
func toData(sig *Signal) (map[string]interface{}, error) {
|
||||
raw, err := json.Marshal(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := make(map[string]interface{})
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
func TestConvertOpen(t *testing.T) {
|
||||
lev := 100
|
||||
c := NewConverter(map[string]config.StrategyOverride{
|
||||
"BLONG": {QuantityMultipliers: config.QuantityMultipliers{Open: 100}, Leverage: &lev},
|
||||
})
|
||||
event, data, err := c.Convert([]byte(`{
|
||||
"signalId":"s1","strategyCode":"BLONG","symbol":"BTCUSDT",
|
||||
"side":"LONG","action":"OPEN","quantity":0.01,"price":64000,
|
||||
"leverage":10,"period":"1h","eventTime":"2026-06-23T01:30:00Z"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "trade.open" {
|
||||
t.Fatalf("event=%q", event)
|
||||
}
|
||||
formatted, _ := data["formatted"].(string)
|
||||
if !strings.Contains(formatted, "周期: 1h") || !strings.Contains(formatted, "开仓数量: 1.00") {
|
||||
t.Fatalf("formatted=\n%s", formatted)
|
||||
}
|
||||
if data["period"] != "1h" || data["strategyCode"] != "BLONG" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if data["leverage"] != float64(100) && data["leverage"] != 100 {
|
||||
t.Fatalf("leverage=%v", data["leverage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInvalidJSON(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMissingAction(t *testing.T) {
|
||||
_, _, err := NewConverter(nil).Convert([]byte(`{"symbol":"BTCUSDT"}`))
|
||||
if !errors.Is(err, ErrInvalidSignal) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FormatOptions struct {
|
||||
AvgPrice *float64
|
||||
}
|
||||
|
||||
func Format(signal *Signal, opts ...FormatOptions) string {
|
||||
var opt FormatOptions
|
||||
if len(opts) > 0 {
|
||||
opt = opts[0]
|
||||
}
|
||||
|
||||
title := actionTitle(signal.Side, signal.Action)
|
||||
symbol := trimQuote(signal.Symbol)
|
||||
|
||||
lines := []string{title}
|
||||
lines = append(lines, fmt.Sprintf("交易品种: %s", symbol))
|
||||
if p := strings.TrimSpace(signal.Period); p != "" {
|
||||
lines = append(lines, fmt.Sprintf("周期: %s", p))
|
||||
}
|
||||
|
||||
action := strings.ToUpper(signal.Action)
|
||||
switch action {
|
||||
case "OPEN":
|
||||
lines = append(lines, fmt.Sprintf("开仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("OPEN", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.Leverage > 0 {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
if signal.TakeProfitPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止盈价格: %.2f", *signal.TakeProfitPrice))
|
||||
}
|
||||
if signal.StopLossPrice != nil {
|
||||
lines = append(lines, fmt.Sprintf("止损价格: %.2f", *signal.StopLossPrice))
|
||||
}
|
||||
case "CLOSE":
|
||||
lines = append(lines, fmt.Sprintf("平仓价格: %.2f", signal.Price))
|
||||
lines = append(lines, closeSizeLine(signal.Quantity, signal.PosMarginRatio))
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("平仓盈亏: %.2f", *signal.PnL))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
}
|
||||
case "ADD":
|
||||
lines = append(lines, fmt.Sprintf("加仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("ADD", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.Leverage > 0 {
|
||||
lines = append(lines, fmt.Sprintf("杠杆: %dx", signal.Leverage))
|
||||
}
|
||||
case "REDUCE":
|
||||
lines = append(lines, fmt.Sprintf("减仓价格: %.2f", signal.Price))
|
||||
if line := sizeLine("REDUCE", signal.Quantity, signal.PosMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
if signal.PnL != nil {
|
||||
lines = append(lines, fmt.Sprintf("减仓盈亏: %.2f", *signal.PnL))
|
||||
}
|
||||
if signal.AccountBalance != nil {
|
||||
lines = append(lines, fmt.Sprintf("账户余额:%.2f", *signal.AccountBalance))
|
||||
}
|
||||
default:
|
||||
lines = append(lines, fmt.Sprintf("价格: %.2f", signal.Price))
|
||||
if line := sizeLine("", signal.Quantity, signal.AmountMarginRatio); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = appendAvgPrice(lines, opt.AvgPrice)
|
||||
}
|
||||
|
||||
if signal.StrategyCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("策略: %s", signal.StrategyCode))
|
||||
}
|
||||
|
||||
eventTime := signal.ParsedEventTime().In(time.Local)
|
||||
lines = append(lines, fmt.Sprintf("Time: %s", eventTime.Format("2006.01.02 15:04:05")))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func actionTitle(side, action string) string {
|
||||
side = strings.ToUpper(side)
|
||||
action = strings.ToUpper(action)
|
||||
|
||||
var pos string
|
||||
switch side {
|
||||
case "LONG":
|
||||
pos = "多单"
|
||||
case "SHORT":
|
||||
pos = "空单"
|
||||
default:
|
||||
pos = side
|
||||
}
|
||||
|
||||
var act string
|
||||
switch action {
|
||||
case "OPEN":
|
||||
act = "开仓"
|
||||
case "ADD":
|
||||
act = "加仓"
|
||||
case "CLOSE":
|
||||
act = "平仓"
|
||||
case "REDUCE":
|
||||
act = "减仓"
|
||||
default:
|
||||
act = action
|
||||
}
|
||||
|
||||
return pos + act
|
||||
}
|
||||
|
||||
func appendAvgPrice(lines []string, avgPrice *float64) []string {
|
||||
if avgPrice == nil || *avgPrice <= 0 {
|
||||
return lines
|
||||
}
|
||||
return append(lines, fmt.Sprintf("平均单价: %.2f", *avgPrice))
|
||||
}
|
||||
|
||||
func closeSizeLine(quantity, posMarginRatio *float64) string {
|
||||
if quantity != nil && *quantity > 0 {
|
||||
return fmt.Sprintf("平仓数量: %.2f", *quantity)
|
||||
}
|
||||
ratio := 1.0
|
||||
if posMarginRatio != nil {
|
||||
ratio = *posMarginRatio
|
||||
}
|
||||
return fmt.Sprintf("平仓比例: %s", formatPercent(ratio))
|
||||
}
|
||||
|
||||
func sizeLine(action string, quantity, marginRatio *float64) string {
|
||||
if quantity != nil && *quantity > 0 {
|
||||
return fmt.Sprintf("%s: %.2f", quantityLabel(action), *quantity)
|
||||
}
|
||||
if marginRatio != nil {
|
||||
return fmt.Sprintf("%s: %s", ratioLabel(action), formatPercent(*marginRatio))
|
||||
}
|
||||
if quantity != nil {
|
||||
return fmt.Sprintf("%s: %.2f", quantityLabel(action), *quantity)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func quantityLabel(action string) string {
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
return "开仓数量"
|
||||
case "ADD":
|
||||
return "加仓数量"
|
||||
case "CLOSE":
|
||||
return "平仓数量"
|
||||
case "REDUCE":
|
||||
return "减仓数量"
|
||||
default:
|
||||
return "数量"
|
||||
}
|
||||
}
|
||||
|
||||
func ratioLabel(action string) string {
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
return "开仓比例"
|
||||
case "ADD":
|
||||
return "加仓比例"
|
||||
case "CLOSE":
|
||||
return "平仓比例"
|
||||
case "REDUCE":
|
||||
return "减仓比例"
|
||||
default:
|
||||
return "仓位比例"
|
||||
}
|
||||
}
|
||||
|
||||
func formatPercent(ratio float64) string {
|
||||
return fmt.Sprintf("%.2f%%", ratio*100)
|
||||
}
|
||||
|
||||
func trimQuote(symbol string) string {
|
||||
symbol = strings.ToUpper(symbol)
|
||||
for _, suffix := range []string{"USDT", "USDC", "BUSD", "USD"} {
|
||||
if strings.HasSuffix(symbol, suffix) && len(symbol) > len(suffix) {
|
||||
return symbol[:len(symbol)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr(v float64) *float64 { return &v }
|
||||
|
||||
func TestFormatOpenIncludesPeriodAfterSymbol(t *testing.T) {
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
|
||||
Quantity: ptr(0.01), Price: 64000.5, Leverage: 10,
|
||||
Period: "1h", EventTime: "2026-06-23T01:30:00Z",
|
||||
})
|
||||
if !strings.Contains(out, "多单开仓") || !strings.Contains(out, "交易品种: BTC") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
idxSym := strings.Index(out, "交易品种: BTC")
|
||||
idxPer := strings.Index(out, "周期: 1h")
|
||||
idxPx := strings.Index(out, "开仓价格:")
|
||||
if idxPer < 0 || idxPer < idxSym || idxPx < idxPer {
|
||||
t.Fatalf("period placement:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOmitsEmptyPeriod(t *testing.T) {
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "OPEN",
|
||||
Price: 1, EventTime: "2026-06-23T01:30:00Z",
|
||||
})
|
||||
if strings.Contains(out, "周期:") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCloseLong(t *testing.T) {
|
||||
pnl, bal := 941.0, 74744.90
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "CLOSE",
|
||||
Quantity: ptr(3), Price: 63175.76,
|
||||
EventTime: "2026-07-07T05:52:14Z", PnL: &pnl, AccountBalance: &bal,
|
||||
})
|
||||
for _, want := range []string{"多单平仓", "平仓价格: 63175.76", "平仓盈亏: 941.00"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWithAvgPrice(t *testing.T) {
|
||||
avg := 150.0
|
||||
out := Format(&Signal{
|
||||
Symbol: "BTCUSDT", Side: "LONG", Action: "ADD",
|
||||
Quantity: ptr(1), Price: 200, EventTime: "2026-07-07T05:52:14Z",
|
||||
}, FormatOptions{AvgPrice: &avg})
|
||||
if !strings.Contains(out, "平均单价: 150.00") {
|
||||
t.Fatalf("%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tradesignal
|
||||
|
||||
import "aiaa-notification-service/internal/config"
|
||||
|
||||
func Apply(signal *Signal, override *config.StrategyOverride) *Signal {
|
||||
if override == nil {
|
||||
return signal
|
||||
}
|
||||
|
||||
out := *signal
|
||||
if signal.Quantity != nil {
|
||||
q := *signal.Quantity
|
||||
out.Quantity = &q
|
||||
}
|
||||
|
||||
if out.Quantity != nil && *out.Quantity > 0 {
|
||||
multiplier := override.QuantityMultiplierFor(out.Action)
|
||||
if multiplier != 1 {
|
||||
q := *out.Quantity * multiplier
|
||||
out.Quantity = &q
|
||||
}
|
||||
}
|
||||
|
||||
if override.Leverage != nil && *override.Leverage > 0 {
|
||||
out.Leverage = *override.Leverage
|
||||
}
|
||||
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package tradesignal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNone mode = iota
|
||||
modeQty
|
||||
modeWeight
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
AvgPrice float64
|
||||
Size float64
|
||||
HasAvg bool
|
||||
}
|
||||
|
||||
type state struct {
|
||||
avg float64
|
||||
size float64
|
||||
mode mode
|
||||
}
|
||||
|
||||
type Tracker struct {
|
||||
mu sync.Mutex
|
||||
positions map[string]*state
|
||||
applied map[string]Snapshot
|
||||
}
|
||||
|
||||
func NewTracker() *Tracker {
|
||||
return &Tracker{
|
||||
positions: make(map[string]*state),
|
||||
applied: make(map[string]Snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) Apply(signal *Signal) Snapshot {
|
||||
if signal == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if signal.SignalID != "" {
|
||||
if snap, ok := t.applied[signal.SignalID]; ok {
|
||||
return snap
|
||||
}
|
||||
}
|
||||
|
||||
key := positionKey(signal.StrategyCode, signal.Symbol, signal.Side)
|
||||
action := strings.ToUpper(signal.Action)
|
||||
st := t.positions[key]
|
||||
|
||||
var snap Snapshot
|
||||
switch action {
|
||||
case "OPEN":
|
||||
st = openPosition(signal)
|
||||
snap = snapshotFrom(st)
|
||||
if st != nil {
|
||||
t.positions[key] = st
|
||||
} else {
|
||||
delete(t.positions, key)
|
||||
}
|
||||
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
|
||||
}
|
||||
case "CLOSE":
|
||||
snap = snapshotFrom(st)
|
||||
delete(t.positions, key)
|
||||
default:
|
||||
snap = snapshotFrom(st)
|
||||
}
|
||||
|
||||
if signal.SignalID != "" {
|
||||
t.applied[signal.SignalID] = snap
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func openPosition(signal *Signal) *state {
|
||||
if qty, ok := positiveQty(signal.Quantity); ok {
|
||||
return &state{avg: signal.Price, size: qty, mode: modeQty}
|
||||
}
|
||||
if w, ok := positiveRatio(signal.AmountMarginRatio); ok {
|
||||
return &state{avg: signal.Price, size: w, mode: modeWeight}
|
||||
}
|
||||
if signal.Price > 0 {
|
||||
return &state{avg: signal.Price, size: 0, mode: modeNone}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPosition(st *state, signal *Signal) *state {
|
||||
if st == nil || st.size <= 0 {
|
||||
return openPosition(signal)
|
||||
}
|
||||
|
||||
if qty, ok := positiveQty(signal.Quantity); ok {
|
||||
if st.mode == modeWeight {
|
||||
return st
|
||||
}
|
||||
if st.mode == modeNone || st.size == 0 {
|
||||
st.mode = modeQty
|
||||
st.size = qty
|
||||
st.avg = signal.Price
|
||||
return st
|
||||
}
|
||||
st.avg = (st.size*st.avg + qty*signal.Price) / (st.size + qty)
|
||||
st.size += qty
|
||||
st.mode = modeQty
|
||||
return st
|
||||
}
|
||||
|
||||
if w, ok := positiveRatio(signal.AmountMarginRatio); ok {
|
||||
if st.mode == modeQty {
|
||||
return st
|
||||
}
|
||||
if st.mode == modeNone || st.size == 0 {
|
||||
st.mode = modeWeight
|
||||
st.size = w
|
||||
st.avg = signal.Price
|
||||
return st
|
||||
}
|
||||
st.avg = (st.size*st.avg + w*signal.Price) / (st.size + w)
|
||||
st.size += w
|
||||
st.mode = modeWeight
|
||||
return st
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func reducePosition(st *state, signal *Signal) *state {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if qty, ok := positiveQty(signal.Quantity); ok && st.mode == modeQty {
|
||||
st.size -= qty
|
||||
if st.size < 0 {
|
||||
st.size = 0
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
ratio := 0.0
|
||||
if r, ok := positiveRatio(signal.PosMarginRatio); ok {
|
||||
ratio = r
|
||||
} else if signal.Quantity == nil && signal.PosMarginRatio == nil {
|
||||
return st
|
||||
}
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
if ratio > 0 {
|
||||
st.size *= (1 - ratio)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func snapshotFrom(st *state) Snapshot {
|
||||
if st == nil || st.avg <= 0 {
|
||||
return Snapshot{}
|
||||
}
|
||||
return Snapshot{
|
||||
AvgPrice: st.avg,
|
||||
Size: st.size,
|
||||
HasAvg: true,
|
||||
}
|
||||
}
|
||||
|
||||
func positiveQty(q *float64) (float64, bool) {
|
||||
if q == nil || *q <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return *q, true
|
||||
}
|
||||
|
||||
func positiveRatio(r *float64) (float64, bool) {
|
||||
if r == nil || *r <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return *r, true
|
||||
}
|
||||
|
||||
func positionKey(strategyCode, symbol, side string) string {
|
||||
return strings.ToUpper(strategyCode) + "|" + strings.ToUpper(symbol) + "|" + strings.ToUpper(side)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tradesignal
|
||||
|
||||
import "time"
|
||||
|
||||
type Signal struct {
|
||||
SignalID string `json:"signalId"`
|
||||
SourcePosID string `json:"sourcePosId"`
|
||||
StrategyCode string `json:"strategyCode"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Action string `json:"action"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
AmountMarginRatio *float64 `json:"amountMarginRatio"`
|
||||
PosMarginRatio *float64 `json:"posMarginRatio"`
|
||||
Price float64 `json:"price"`
|
||||
Leverage int `json:"leverage"`
|
||||
Period string `json:"period"`
|
||||
EventTime string `json:"eventTime"`
|
||||
TakeProfitPrice *float64 `json:"takeProfitPrice"`
|
||||
StopLossPrice *float64 `json:"stopLossPrice"`
|
||||
TakeProfitRatio *float64 `json:"takeProfitRatio"`
|
||||
StopLossRatio *float64 `json:"stopLossRatio"`
|
||||
PnL *float64 `json:"pnl"`
|
||||
AccountBalance *float64 `json:"accountBalance"`
|
||||
}
|
||||
|
||||
func (s *Signal) ParsedEventTime() time.Time {
|
||||
if s.EventTime == "" {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s.EventTime)
|
||||
if err != nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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 TestAvgPriceOpenAndAdd(t *testing.T) {
|
||||
tr := NewTracker()
|
||||
|
||||
open := &Signal{
|
||||
SignalID: "s1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(2),
|
||||
Price: 100,
|
||||
}
|
||||
snap := tr.Apply(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 = tr.Apply(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,
|
||||
}
|
||||
tr.Apply(open)
|
||||
|
||||
add := &Signal{
|
||||
SignalID: "r2",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "ETHUSDT",
|
||||
Side: "LONG",
|
||||
Action: "ADD",
|
||||
AmountMarginRatio: ptr(0.1),
|
||||
Price: 200,
|
||||
}
|
||||
snap := tr.Apply(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()
|
||||
tr.Apply(&Signal{
|
||||
SignalID: "c1",
|
||||
StrategyCode: "BLONG",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "LONG",
|
||||
Action: "OPEN",
|
||||
Quantity: ptr(1),
|
||||
Price: 64000,
|
||||
})
|
||||
|
||||
snap := tr.Apply(&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 = tr.Apply(&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,
|
||||
}
|
||||
tr.Apply(sig)
|
||||
tr.Apply(sig)
|
||||
|
||||
snap := tr.Apply(&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()
|
||||
tr.Apply(&Signal{
|
||||
SignalID: "l1", StrategyCode: "BLONG", Symbol: "BTCUSDT", Side: "LONG",
|
||||
Action: "OPEN", Quantity: ptr(1), Price: 100,
|
||||
})
|
||||
snap := tr.Apply(&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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user