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)