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:
+102
-6
@@ -4,17 +4,107 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
SMTP SMTPConfig `mapstructure:"smtp"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Logbull LogbullConfig `mapstructure:"logbull"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
SMTP SMTPConfig `mapstructure:"smtp"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Logbull LogbullConfig `mapstructure:"logbull"`
|
||||
Subscriptions []SubscriptionConfig `mapstructure:"subscriptions"`
|
||||
SubscriptionDedupTTL time.Duration `mapstructure:"subscription_dedup_ttl"`
|
||||
}
|
||||
|
||||
type SubscriptionConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Queue string `mapstructure:"queue"`
|
||||
DeadLetterQueue string `mapstructure:"dead_letter_queue"`
|
||||
Exchange string `mapstructure:"exchange"`
|
||||
ExchangeType string `mapstructure:"exchange_type"`
|
||||
RoutingKey string `mapstructure:"routing_key"`
|
||||
MaxRetry int `mapstructure:"max_retry"`
|
||||
Source string `mapstructure:"source"`
|
||||
Formatter string `mapstructure:"formatter"`
|
||||
StrategyOverrides map[string]StrategyOverride `mapstructure:"strategy_overrides"`
|
||||
}
|
||||
|
||||
type StrategyOverride struct {
|
||||
QuantityMultipliers QuantityMultipliers `mapstructure:"quantity_multipliers"`
|
||||
Leverage *int `mapstructure:"leverage"`
|
||||
}
|
||||
|
||||
type QuantityMultipliers struct {
|
||||
Open float64 `mapstructure:"open"`
|
||||
Add float64 `mapstructure:"add"`
|
||||
Reduce float64 `mapstructure:"reduce"`
|
||||
Close float64 `mapstructure:"close"`
|
||||
}
|
||||
|
||||
func (o StrategyOverride) QuantityMultiplierFor(action string) float64 {
|
||||
var v float64
|
||||
switch strings.ToUpper(action) {
|
||||
case "OPEN":
|
||||
v = o.QuantityMultipliers.Open
|
||||
case "ADD":
|
||||
v = o.QuantityMultipliers.Add
|
||||
case "REDUCE":
|
||||
v = o.QuantityMultipliers.Reduce
|
||||
case "CLOSE":
|
||||
v = o.QuantityMultipliers.Close
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if v <= 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *Config) NormalizeSubscriptions() error {
|
||||
for i := range c.Subscriptions {
|
||||
s := &c.Subscriptions[i]
|
||||
if s.URL == "" {
|
||||
continue
|
||||
}
|
||||
if s.Queue == "" {
|
||||
return fmt.Errorf("subscriptions[%d]: queue is required", i)
|
||||
}
|
||||
if s.Source == "" {
|
||||
return fmt.Errorf("subscriptions[%d]: source is required", i)
|
||||
}
|
||||
if s.Name == "" {
|
||||
s.Name = s.Queue
|
||||
}
|
||||
if s.MaxRetry <= 0 {
|
||||
s.MaxRetry = 3
|
||||
}
|
||||
if s.ExchangeType == "" {
|
||||
s.ExchangeType = "fanout"
|
||||
}
|
||||
if s.Formatter == "" {
|
||||
s.Formatter = "trade_signal"
|
||||
}
|
||||
if s.Formatter != "trade_signal" {
|
||||
return fmt.Errorf("subscriptions[%d]: unknown formatter %q", i, s.Formatter)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ActiveSubscriptions() []SubscriptionConfig {
|
||||
out := make([]SubscriptionConfig, 0, len(c.Subscriptions))
|
||||
for _, s := range c.Subscriptions {
|
||||
if s.URL != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -90,6 +180,12 @@ func Load(path string) (*Config, error) {
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.SubscriptionDedupTTL <= 0 {
|
||||
cfg.SubscriptionDedupTTL = time.Hour
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeSubscriptionDefaults(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
URL: "amqps://example.invalid/vhost",
|
||||
Queue: "trade.signal.notify.queue",
|
||||
Source: "trade-signal",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := cfg.Subscriptions[0]
|
||||
if s.Name != "trade.signal.notify.queue" {
|
||||
t.Fatalf("name=%q", s.Name)
|
||||
}
|
||||
if s.MaxRetry != 3 {
|
||||
t.Fatalf("max_retry=%d", s.MaxRetry)
|
||||
}
|
||||
if s.ExchangeType != "fanout" {
|
||||
t.Fatalf("exchange_type=%q", s.ExchangeType)
|
||||
}
|
||||
if s.Formatter != "trade_signal" {
|
||||
t.Fatalf("formatter=%q", s.Formatter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSkipsEmptyURL(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
Queue: "q", Source: "s",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(cfg.ActiveSubscriptions()); n != 0 {
|
||||
t.Fatalf("active=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUnknownFormatter(t *testing.T) {
|
||||
cfg := &Config{Subscriptions: []SubscriptionConfig{{
|
||||
URL: "amqps://example.invalid/vhost", Queue: "q", Source: "s", Formatter: "other",
|
||||
}}}
|
||||
if err := cfg.NormalizeSubscriptions(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuantityMultiplierFor(t *testing.T) {
|
||||
o := StrategyOverride{QuantityMultipliers: QuantityMultipliers{Open: 100, Add: 0}}
|
||||
if o.QuantityMultiplierFor("OPEN") != 100 {
|
||||
t.Fatalf("open=%v", o.QuantityMultiplierFor("OPEN"))
|
||||
}
|
||||
if o.QuantityMultiplierFor("ADD") != 1 {
|
||||
t.Fatalf("add<=0 should be 1, got %v", o.QuantityMultiplierFor("ADD"))
|
||||
}
|
||||
if o.QuantityMultiplierFor("UNKNOWN") != 1 {
|
||||
t.Fatalf("unknown=%v", o.QuantityMultiplierFor("UNKNOWN"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user