DEV Community

Cover image for Construindo um Bot de Trailing Stop com Go, RabbitMQ e Bounded Contexts
Cláudio Filipe Lima Rapôso
Cláudio Filipe Lima Rapôso

Posted on

Construindo um Bot de Trailing Stop com Go, RabbitMQ e Bounded Contexts

Você quer proteger seus lucros sem vigiar o gráfico o dia todo. Um Trailing Stop faz exatamente isso: acompanha o movimento favorável do preço e, ao detectar uma reversão além de um percentual configurado, fecha a posição automaticamente. Mas depender de uma ordem nativa da corretora pode ser limitante (parâmetros, disponibilidade, validações). E um monólito que faz de tudo fica difícil de escalar e observar.

Nossa proposta: um sistema distribuído e desacoplado, onde cada parte cuida de uma responsabilidade única, comunicando-se por mensagens. Assim, trocamos rigidez por flexibilidade e evolutividade.


O que vamos construir (foto geral)

Diagrama de Sequência

  • MarketData: um serviço que ouve o WebSocket da Binance e publica preços em tempo real.
  • Orchestrator: o cérebro da estratégia. Mantém topo/fundo, calcula a variação e decide quando enviar uma ordem.
  • Orders: recebe comandos do orquestrador, envia a ordem à corretora (ou simula) e publica o resultado.
  • RabbitMQ: nosso barramento, com três exchanges: marketdata.events (fanout), order.commands (direct) e order.events (fanout).
  • OpenTelemetry: pronto para instrumentação e tracing (arquivo internal/tracing/otel.go).

Decisões arquiteturais-chave

  1. Bounded contexts para separação de responsabilidades e escala independente.
  2. Mensageria para desacoplamento (produtores não sabem quem consome e vice-versa).
  3. Contratos de mensagens estáveis entre serviços (PriceTick, PlaceOrder, OrderResult).
  4. Execução simulável: sem credenciais, ainda validamos o fluxo com segurança.

Pré-requisitos

  • Go 1.22+
  • Docker e Docker Compose
  • Conta na Binance Spot Testnet (opcional para simulação; obrigatório para ordens reais)

1. Iniciando o projeto

Estrutura do repositório:

trailingstop-bc/ ├─ .env.example ├─ docker-compose.yml ├─ go.mod / go.sum ├─ cmd/ │ ├─ marketdata/ │ ├─ orchestrator/ │ └─ order/ └─ internal/ ├─ messaging/ ├─ tracing/ └─ types/ 
Enter fullscreen mode Exit fullscreen mode

go.mod

Define módulo e dependências. O nome do módulo acompanha o repositório.

module github.com/sertaoseracloud/trailingstop-bc go 1.22 require ( github.com/adshao/go-binance/v2 v2.8.5 github.com/rabbitmq/amqp091-go v1.10.0 ) 
Enter fullscreen mode Exit fullscreen mode

Por quê?

  • go-binance dá acesso a REST/WS da Binance.
  • amqp091-go é o cliente AMQP para o RabbitMQ.

2. Contratos de mensagens (fontes da verdade entre serviços)

internal/types/messages.go

package types import "time" // PriceTick é publicado pelo MarketData. type PriceTick struct { Symbol string `json:"symbol"` Price float64 `json:"price"` Ts int64 `json:"ts"` } // PlaceOrder é comando do Orchestrator para Orders. type PlaceOrder struct { Symbol string `json:"symbol"` Side string `json:"side"` // "BUY" | "SELL" Qty float64 `json:"qty"` Type string `json:"type"` // "MARKET" | "LIMIT" ClientID string `json:"clientId,omitempty"` } // OrderResult é evento de confirmação/erro do Orders. type OrderResult struct { Symbol string `json:"symbol"` Side string `json:"side"` Status string `json:"status"` OrderID int64 `json:"orderId"` FilledQty float64 `json:"filledQty"` Price float64 `json:"price"` Ts int64 `json:"ts"` Error string `json:"error,omitempty"` } func NowMs() int64 { return time.Now().UnixMilli() } 
Enter fullscreen mode Exit fullscreen mode

Decisão: mensagens simples em JSON. Isso facilita observabilidade, reprocessamento e compatibilidade com outras linguagens.


3. Camada de mensageria (encapsulando RabbitMQ)

internal/messaging/rabbitmq.go

package messaging import ( "context" "encoding/json" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) const ( ExMarketData = "marketdata.events" // fanout ExOrderCmds = "order.commands" // direct ExOrderEvts = "order.events" // fanout ) type Bus struct{ Conn *amqp.Connection Ch *amqp.Channel } func MustBus() *Bus { url := os.Getenv("AMQP_URL") if url == "" { url = "amqp://guest:guest@localhost:5672/" } conn, err := amqp.Dial(url) if err != nil { log.Fatalf("amqp dial: %v", err) } ch, err := conn.Channel() if err != nil { log.Fatalf("amqp channel: %v", err) } must(ch.ExchangeDeclare(ExMarketData, "fanout", true, false, false, false, nil)) must(ch.ExchangeDeclare(ExOrderCmds, "direct", true, false, false, false, nil)) must(ch.ExchangeDeclare(ExOrderEvts, "fanout", true, false, false, false, nil)) return &Bus{Conn: conn, Ch: ch} } func must(err error) { if err != nil { log.Fatal(err) } } func (b *Bus) Close() { _ = b.Ch.Close(); _ = b.Conn.Close() } func (b *Bus) PublishJSON(ex, key string, v any) error { body, err := json.Marshal(v) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return b.Ch.PublishWithContext(ctx, ex, key, false, false, amqp.Publishing{ ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, Timestamp: time.Now(), }) } // Consumo fanout: fila efêmera/exclusiva para broadcast (ideal para market data). func (b *Bus) ConsumeFanout(ex string) (<-chan amqp.Delivery, func()) { q, err := b.Ch.QueueDeclare("", false, true, true, false, nil) must(err) must(b.Ch.QueueBind(q.Name, "", ex, false, nil)) msgs, err := b.Ch.Consume(q.Name, "", true, true, false, false, nil) must(err) cancel := func(){ _ = b.Ch.QueueUnbind(q.Name, "", ex, nil); _ = b.Ch.QueueDelete(q.Name, false, false, true) } return msgs, cancel } // Consumo direct: fila durável com routing key (ideal para comandos de ordem). func (b *Bus) ConsumeDirect(ex, queue, key string) (<-chan amqp.Delivery, error) { q, err := b.Ch.QueueDeclare(queue, true, false, false, false, nil) if err != nil { return nil, err } if err = b.Ch.QueueBind(q.Name, key, ex, false, nil); err != nil { return nil, err } return b.Ch.Consume(q.Name, "", true, false, false, false, nil) } 
Enter fullscreen mode Exit fullscreen mode

Decisão: abstrair conexão/publicação/consumo torna os serviços mais legíveis e reduz repetição.


4. MarketData: consumindo a Binance em tempo real

cmd/marketdata/main.go

package main import ( "log" "os" "strconv" binance "github.com/adshao/go-binance/v2" "github.com/sertaoseracloud/trailingstop-bc/internal/messaging" "github.com/sertaoseracloud/trailingstop-bc/internal/types" ) func main() { symbol := os.Getenv("SYMBOL") if symbol == "" { symbol = "BTCUSDT" } if os.Getenv("BINANCE_TESTNET") == "true" { binance.UseTestnet = true } bus := messaging.MustBus() defer bus.Close() log.Printf("[marketdata] WS AggTrade %s (testnet=%v)", symbol, binance.UseTestnet) handler := func(e *binance.WsAggTradeEvent) { price, err := strconv.ParseFloat(e.Price, 64) if err != nil { return } tick := types.PriceTick{ Symbol: symbol, Price: price, Ts: types.NowMs() } _ = bus.PublishJSON(messaging.ExMarketData, "", tick) } errHandler := func(err error) { log.Printf("ws err: %v", err) } doneC, stopC, err := binance.WsAggTradeServe(symbol, handler, errHandler) if err != nil { log.Fatal(err) } <-doneC; close(stopC) } 
Enter fullscreen mode Exit fullscreen mode

Por quê assim?

  • O canal AggTrade traz um fluxo rico de preços.
  • Publicar em marketdata.events (fanout) permite múltiplos consumidores (ex.: backtests, dashboards) sem acoplamento.

5. Orchestrator: o cérebro do trailing

cmd/orchestrator/main.go

package main import ( "encoding/json" "log" "math" "os" "strconv" "strings" "github.com/sertaoseracloud/trailingstop-bc/internal/messaging" "github.com/sertaoseracloud/trailingstop-bc/internal/types" ) func getenvf(key string, def float64) float64 { v := os.Getenv(key); if v == "" { return def } f, err := strconv.ParseFloat(v, 64); if err != nil { return def } return f } func main(){ symbol := os.Getenv("SYMBOL"); if symbol == "" { symbol = "BTCUSDT" } side := strings.ToUpper(os.Getenv("SIDE")); if side == "" { side = "SELL" } qty := getenvf("QTY", 0.001) trailing := getenvf("TRAILING_PERCENT", 1.0) activation := getenvf("ACTIVATION_PRICE", 0) bus := messaging.MustBus(); defer bus.Close() msgs, cancel := bus.ConsumeFanout(messaging.ExMarketData); defer cancel() log.Printf("[orchestrator] %s side=%s qty=%.6f trailing=%.4f%% activation=%.4f", symbol, side, qty, trailing, activation) activated := false ref := 0.0 // topo (SELL) ou fundo (BUY) fired := false for d := range msgs { var tick types.PriceTick if err := json.Unmarshal(d.Body, &tick); err != nil || tick.Symbol != symbol { continue } p := tick.Price if !activated { if activation == 0 || (side == "SELL" && p >= activation) || (side == "BUY" && p <= activation) { activated, ref = true, p log.Printf("[orchestrator] ativado em %.8f", p) } continue } if side == "SELL" { if p > ref { ref = p } // atualiza topo drop := (ref - p) / ref * 100 if !fired && drop >= trailing { cmd := types.PlaceOrder{ Symbol: symbol, Side: "SELL", Qty: qty, Type: "MARKET" } _ = bus.PublishJSON(messaging.ExOrderCmds, "place_order", cmd) fired = true log.Printf("[orchestrator] disparo SELL: topo=%.8f atual=%.8f queda=%.4f%%", ref, p, drop) } } else { // BUY if ref == 0 || p < ref { ref = p } // guarda fundo rise := (p - ref) / math.Max(ref, 1e-9) * 100 if !fired && rise >= trailing { cmd := types.PlaceOrder{ Symbol: symbol, Side: "BUY", Qty: qty, Type: "MARKET" } _ = bus.PublishJSON(messaging.ExOrderCmds, "place_order", cmd) fired = true log.Printf("[orchestrator] disparo BUY: fundo=%.8f atual=%.8f alta=%.4f%%", ref, p, rise) } } } } 
Enter fullscreen mode Exit fullscreen mode

Decisão: a estratégia roda dentro do orquestrador (e não na corretora). Isso nos dá controle total, facilita simulação e permite trocar regras sem tocar em MarketData ou Orders.


6. Orders: a ponte com a corretora

cmd/order/main.go

package main import ( "context" "encoding/json" "log" "os" "strconv" binance "github.com/adshao/go-binance/v2" "github.com/sertaoseracloud/trailingstop-bc/internal/messaging" "github.com/sertaoseracloud/trailingstop-bc/internal/types" ) func main(){ if os.Getenv("BINANCE_TESTNET") == "true" { binance.UseTestnet = true } apiKey, apiSecret := os.Getenv("BINANCE_API_KEY"), os.Getenv("BINANCE_API_SECRET") bus := messaging.MustBus(); defer bus.Close() msgs, err := bus.ConsumeDirect(messaging.ExOrderCmds, "orders.place", "place_order") if err != nil { log.Fatal(err) } client := binance.NewClient(apiKey, apiSecret) log.Printf("[order] pronto (testnet=%v)", binance.UseTestnet) for d := range msgs { var cmd types.PlaceOrder if err := json.Unmarshal(d.Body, &cmd); err != nil { continue } res := types.OrderResult{ Symbol: cmd.Symbol, Side: cmd.Side, Ts: types.NowMs() } if apiKey == "" || apiSecret == "" { res.Status, res.FilledQty = "SIMULATED", cmd.Qty _ = bus.PublishJSON(messaging.ExOrderEvts, "", res) log.Printf("[order] (simulado) %s %f %s", cmd.Side, cmd.Qty, cmd.Symbol) continue } sideType := binance.SideTypeSell; if cmd.Side == "BUY" { sideType = binance.SideTypeBuy } svc := client.NewCreateOrderService(). Symbol(cmd.Symbol). Side(sideType). Type(binance.OrderTypeMarket). Quantity(strconv.FormatFloat(cmd.Qty, 'f', -1, 64)) ord, err := svc.Do(context.Background()) if err != nil { res.Status, res.Error = "REJECTED", err.Error() _ = bus.PublishJSON(messaging.ExOrderEvts, "", res) log.Printf("[order] erro: %v", err) continue } res.Status, res.OrderID = string(ord.Status), ord.OrderID _ = bus.PublishJSON(messaging.ExOrderEvts, "", res) log.Printf("[order] enviado %s qty=%s symbol=%s id=%d status=%s", cmd.Side, ord.OrigQuantity, cmd.Symbol, ord.OrderID, ord.Status) } } 
Enter fullscreen mode Exit fullscreen mode

Decisões

  • Dry-run automático: sem chaves, o serviço publica um OrderResult simulado.
  • MARKET por simplicidade: evita detalhes de filters; você pode evoluir para ordens nativas de trailing depois.

7. Observabilidade (OpenTelemetry)

internal/tracing/otel.go (exemplo mínimo)

package tracing import ( "context" "log" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func Init() func(context.Context) error { exp, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) if err != nil { log.Fatalf("otel exporter: %v", err) } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp), ) otel.SetTracerProvider(tp) return tp.Shutdown } 
Enter fullscreen mode Exit fullscreen mode

Por quê?

  • Facilita acompanhar o caminho de uma ordem e diagnosticar gargalos.

8. Docker Compose e configuração

.env.example

AMQP_URL=amqp://guest:guest@rabbitmq:5672/ SYMBOL=BTCUSDT BINANCE_TESTNET=true SIDE=SELL QTY=0.001 TRAILING_PERCENT=1.0 ACTIVATION_PRICE=0 BINANCE_API_KEY= BINANCE_API_SECRET= 
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

version: "3.9" services: rabbitmq: image: rabbitmq:3.12-management ports: - "5672:5672" - "15672:15672" healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] interval: 10s timeout: 5s retries: 5 marketdata: build: ./cmd/marketdata environment: - AMQP_URL=${AMQP_URL} - SYMBOL=${SYMBOL} - BINANCE_TESTNET=${BINANCE_TESTNET} depends_on: rabbitmq: condition: service_healthy orchestrator: build: ./cmd/orchestrator environment: - AMQP_URL=${AMQP_URL} - SYMBOL=${SYMBOL} - SIDE=${SIDE} - QTY=${QTY} - TRAILING_PERCENT=${TRAILING_PERCENT} - ACTIVATION_PRICE=${ACTIVATION_PRICE} depends_on: rabbitmq: condition: service_healthy order: build: ./cmd/order environment: - AMQP_URL=${AMQP_URL} - BINANCE_TESTNET=${BINANCE_TESTNET} - BINANCE_API_KEY=${BINANCE_API_KEY} - BINANCE_API_SECRET=${BINANCE_API_SECRET} depends_on: rabbitmq: condition: service_healthy 
Enter fullscreen mode Exit fullscreen mode

Dicas rápidas

  • UI do RabbitMQ em http://localhost:15672 (guest/guest).
  • Escale serviços conforme a carga:
 docker compose up --build --scale orchestrator=2 --scale order=2 
Enter fullscreen mode Exit fullscreen mode

9. Rodando e validando

  1. Crie o .env a partir do exemplo e ajuste parâmetros.
  2. Suba tudo com docker compose up --build.
  3. Observe nos logs:
  • marketdata: conexão WS e publicação de PriceTick.
  • orchestrator: ativação da estratégia, atualização de topo/fundo e disparo quando TRAILING_PERCENT é atingido.
  • order: ordem simulada (sem chaves) ou real (com chaves), seguido de OrderResult.

Sanidade: se o mercado estiver parado, ajuste TRAILING_PERCENT para valores menores ou teste em outro SYMBOL.


10. Para onde ir a partir daqui

  • Trailing nativo: migrar envio para tipos de ordem que aceitam trailingDelta.
  • Múltiplos pares: instâncias por símbolo com routing key específica.
  • Persistência: salvar estado do trailing (Redis/Postgres) para tolerância a falhas.
  • Observabilidade pro: Prometheus + Grafana, traces para latências por etapa.

Conclusão

Você construiu um bot de trailing stop moderno, desacoplado e observável. Mais importante: entendeu por que cada peça existe. A partir daqui, dá para adaptar a estratégia, integrar novas exchanges, ou evoluir para ordens nativas — tudo sem desmontar o resto do sistema. Bora operar com segurança e arquitetura bem pensada? 🙌


💡Curtiu?

Se quiser trocar ideia sobre IA, cloud e arquitetura, me segue nas redes:

Publico conteúdos técnicos direto do campo de batalha. E quando descubro uma ferramenta que economiza tempo e resolve bem, como essa, você fica sabendo também.

Top comments (0)