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-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() }
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
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
}
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
.env
a 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)