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)
- 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) eorder.events(fanout).
- 
OpenTelemetry: pronto para instrumentação e tracing (arquivo internal/tracing/otel.go).
Decisões arquiteturais-chave
- Bounded contexts para separação de responsabilidades e escala independente.
- Mensageria para desacoplamento (produtores não sabem quem consome e vice-versa).
- 
Contratos de mensagens estáveis entre serviços (PriceTick,PlaceOrder,OrderResult).
- 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/
  
  
  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
)
Por quê?
- 
go-binancedá 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() }
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)
}
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)
}
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)
            }
        }
    }
}
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)
    }
}
Decisões
- 
Dry-run automático: sem chaves, o serviço publica um OrderResultsimulado.
- 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
}
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=
  
  
  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
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
9. Rodando e validando
- Crie o .enva partir do exemplo e ajuste parâmetros.
- Suba tudo com docker compose up --build.
- Observe nos logs:
- 
marketdata: conexão WS e publicação dePriceTick.
- 
orchestrator: ativação da estratégia, atualização de topo/fundo e disparo quandoTRAILING_PERCENTé atingido.
- 
order: ordem simulada (sem chaves) ou real (com chaves), seguido deOrderResult.
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)