Olá, pessoal!
Continuando a série My Broker B3, chegamos em um dos serviços mais ricos em regras de negócio do ecossistema: o Broker Wallet API.
Se nos posts anteriores construímos a infraestrutura da B3 (sync de preços e matching engine), agora entramos no coração financeiro da corretora. Este serviço é o guardião do dinheiro e dos ativos do investidor — ele precisa ser correto acima de tudo.
🏦 O que é a Wallet API?
A trading-broker-wallet é o serviço de custódia financeira do ecossistema. Sua responsabilidade é reagir aos eventos do ciclo de vida de uma ordem e garantir que o dinheiro e os ativos do usuário sejam gerenciados corretamente em cada etapa.
Ela não recebe comandos diretos de compra — ela escuta eventos Kafka e age de acordo com o status de cada ordem:
Kafka: order-events-v1
│
┌────┴────────────────────────┐
│ │
PENDING FILLED / REJECTED
│ │
reserveBalance() settleOrder() / refundBalance()
│ │
Bloqueia saldo Liquida ou estorna
🎯 Por que a Wallet é tão crítica?
Imagine o seguinte cenário sem uma custódia bem implementada:
- Usuário tem R$ 1.000 disponíveis
- Coloca uma ordem de compra de R$ 800
- Antes da ordem ser processada, coloca outra ordem de R$ 500
- Ambas as ordens são executadas → usuário fica com saldo negativo
A wallet resolve isso com o conceito de saldo bloqueado: no momento em que uma ordem entra como PENDING, o valor é imediatamente reservado. O saldo disponível é sempre balance - blockedBalance.
🛠️ Stack Tecnológica
| Tecnologia | Uso |
|---|---|
| Java 21 + Spring Boot 3.5.11 | Core do serviço |
| MySQL + Flyway | Persistência operacional e versionamento |
| Spring Kafka | Consumo de eventos de ordem |
| Spring Data Redis | Preços em tempo real para valorização da carteira |
| SpringDoc OpenAPI | Documentação via Swagger UI |
🏗️ O Modelo de Dados
Antes de entrar na lógica, é importante entender as três entidades que sustentam o serviço:
Account
├── userId (UNIQUE)
├── balance ← saldo total (inclui o bloqueado)
├── blockedBalance ← valor reservado para ordens PENDING
└── currency
Account (1) ──▶ (N) Position
├── ticker
├── quantity
└── averagePrice
WalletTransaction (auditoria)
├── orderId
├── userId
├── transactionType ← RESERVE | SETTLEMENT | REFUND
└── amount
O blockedBalance é a peça central da custódia. O balance nunca desce abaixo de zero — apenas o blockedBalance sobe e desce conforme as ordens avançam no ciclo de vida.
🔄 O Ciclo de Vida de uma Ordem na Wallet
Etapa 1 — PENDING: Reservando o Saldo
Quando uma ordem é criada, ela chega como PENDING. A wallet precisa bloquear imediatamente o valor máximo que pode ser debitado (quantidade × preço limite):
@Transactional
public void reserveBalance(OrderEventDTO event) {
BigDecimal amountToReserve = event.getPrice().multiply(event.getQuantity());
Account account = accountRepository.findByUserId(event.getUserId())
.orElseThrow(() -> new RuntimeException("Account not found for user: " + event.getUserId()));
// Available = balance - blockedBalance
BigDecimal availableBalance = account.getAvailableBalance();
if (availableBalance.compareTo(amountToReserve) < 0) {
throw new RuntimeException("Insufficient balance to reserve funds.");
}
// Only blockedBalance increases — total balance stays intact
account.setBlockedBalance(account.getBlockedBalance().add(amountToReserve));
accountRepository.save(account);
// Audit record
walletTransactionRepository.save(WalletTransaction.builder()
.userId(event.getUserId())
.orderId(event.getOrderId())
.transactionType(TransactionType.RESERVE)
.amount(amountToReserve)
.build());
}
Ponto importante: o balance total não muda nesta etapa. Apenas o blockedBalance aumenta. O dinheiro ainda está na conta — só está reservado.
Etapa 2a — FILLED: Liquidando a Ordem
A ordem foi executada no Matching Engine. Agora sim o dinheiro sai de verdade. Mas aqui temos uma sutileza: o preço executado pode ser diferente do preço reservado.
Exemplo: usuário colocou ordem de compra com limite de R$ 30,00, mas o mercado executou a R$ 29,50. O valor reservado foi R$ 300 (10 ações × R$ 30), mas o real executado foi R$ 295 (10 × R$ 29,50). Os R$ 5,00 de diferença ficam disponíveis automaticamente.
@Transactional
public void settleOrder(OrderEventDTO event) {
BigDecimal totalReserved = event.getPrice().multiply(event.getQuantity());
BigDecimal totalExecuted = event.getExecutedPrice().multiply(event.getQuantity());
Account account = accountRepository.findByUserId(event.getUserId())
.orElseThrow(() -> new RuntimeException("Account not found"));
// Release the initial block
BigDecimal newBlockedBalance = account.getBlockedBalance().subtract(totalReserved);
account.setBlockedBalance(newBlockedBalance.compareTo(BigDecimal.ZERO) < 0
? BigDecimal.ZERO
: newBlockedBalance);
// Debit the actual executed amount from total balance
account.setBalance(account.getBalance().subtract(totalExecuted));
accountRepository.save(account);
// Update asset position
updatePosition(event, account);
}
Etapa 2b — REJECTED: Estornando o Saldo
A ordem foi rejeitada (preço fora do mercado, ticker não encontrado no Redis, etc.). O dinheiro bloqueado volta a ficar disponível:
@Transactional
public void refundBalance(OrderEventDTO event) {
BigDecimal amountToRefund = event.getPrice().multiply(event.getQuantity());
Account account = accountRepository.findByUserId(event.getUserId())
.orElseThrow(() -> new RuntimeException("Account not found for refund"));
// Only subtract from blockedBalance — total balance was never touched
if (account.getBlockedBalance().compareTo(amountToRefund) >= 0) {
account.setBlockedBalance(account.getBlockedBalance().subtract(amountToRefund));
} else {
log.warn("Refund amount exceeds blocked balance for order {}", event.getOrderId());
account.setBlockedBalance(BigDecimal.ZERO);
}
accountRepository.save(account);
}
📊 Cálculo de Preço Médio Ponderado
Quando uma compra é liquidada, precisamos atualizar a posição do usuário naquele ativo. O preço médio ponderado garante que compras em momentos diferentes sejam refletidas corretamente:
Novo Preço Médio = (Custo Atual + Custo Novo) / (Qtd Atual + Qtd Nova)
private void updatePosition(OrderEventDTO event, Account account) {
Position position = positionRepository
.findByAccountIdAndTicker(account.getId(), event.getTicker())
.orElse(Position.builder()
.account(account)
.ticker(event.getTicker())
.quantity(BigDecimal.ZERO)
.averagePrice(BigDecimal.ZERO)
.build());
boolean isSell = "SELL".equalsIgnoreCase(event.getSide());
if (isSell) {
BigDecimal newQuantity = position.getQuantity().subtract(event.getQuantity());
if (newQuantity.compareTo(BigDecimal.ZERO) <= 0) {
positionRepository.delete(position);
return;
}
position.setQuantity(newQuantity);
// Average price stays the same on sell
} else {
// BUY: recalculate weighted average price
BigDecimal currentCost = position.getAveragePrice().multiply(position.getQuantity());
BigDecimal newCost = event.getExecutedPrice().multiply(event.getQuantity());
BigDecimal totalQuantity = position.getQuantity().add(event.getQuantity());
BigDecimal newAveragePrice = currentCost.add(newCost)
.divide(totalQuantity, 4, RoundingMode.HALF_UP);
position.setQuantity(totalQuantity);
position.setAveragePrice(newAveragePrice);
}
positionRepository.save(position);
}
Exemplo prático:
- Compra 1: 10 ações de PETR4 a R$ 30,00 → preço médio = R$ 30,00
- Compra 2: 5 ações de PETR4 a R$ 33,00 → preço médio = (300 + 165) / 15 = R$ 31,00
🐛 Bugs Críticos Corrigidos
Durante a revisão do código, identifiquei problemas que causariam falhas silenciosas em produção:
1. NPE garantido no primeiro depósito
Conta nova criada sem inicializar blockedBalance. A primeira chamada a getAvailableBalance() explodiria com NullPointerException.
// ❌ Antes
Account.builder()
.balance(BigDecimal.ZERO)
// blockedBalance ausente → NPE
// ✅ Depois
Account.builder()
.balance(BigDecimal.ZERO)
.blockedBalance(BigDecimal.ZERO)
2. Saque validava saldo total, não disponível
Permitia sacar dinheiro bloqueado em ordens pendentes:
// ❌ Antes — permite sacar fundos reservados
if (account.getBalance().compareTo(amount) < 0)
// ✅ Depois — valida apenas o disponível
if (account.getAvailableBalance().compareTo(amount) < 0)
3. SELL aumentava posição em vez de diminuir
O método updatePosition() sempre somava quantidade, ignorando completamente o campo side. Uma venda de 10 ações adicionava 10 à posição.
4. Consumer Kafka engolia exceções silenciosamente
Erros eram logados mas o offset era confirmado — a mensagem era descartada sem retry. Agora o consumer relança a exceção para o Kafka aplicar a política de retry corretamente.
🔒 Idempotência
Para evitar duplicidade em caso de reprocessamento Kafka, adicionamos uma constraint de banco:
-- V4 migration
ALTER TABLE wallet_transactions
ADD CONSTRAINT uq_order_transaction UNIQUE (order_id, transaction_type);
Se o mesmo evento PENDING chegar duas vezes, o segundo insert vai falhar com violação de constraint — evitando que o saldo seja bloqueado duas vezes para a mesma ordem.
🌐 API REST
| Método | Endpoint | Descrição |
|---|---|---|
| POST | /api/v1/wallet/{userId}/deposit |
Depositar fundos |
| POST | /api/v1/wallet/{userId}/withdraw |
Sacar fundos disponíveis |
| GET | /api/v1/wallet/{userId}/summary |
Resumo: saldo + posições + patrimônio total |
| GET | /api/v1/wallet/{userId}/positions |
Listar posições em ativos |
| GET | /api/v1/wallet/{userId}/transactions |
Histórico de transações |
📄 Swagger UI: http://localhost:8085/swagger-ui.html
✅ Validando a Execução
Com a aplicação rodando localmente:
- ✅ Flyway aplicou as 4 migrations com sucesso
- ✅ Hibernate validou o schema
- ✅ Kafka consumer conectado e subscrito ao tópico
order-events-v1 - ✅ Consumer group
trading-broker-walletsincronizado - ✅ Swagger UI com 5 endpoints documentados em
http://localhost:8085/swagger-ui.html
🚀 O que vem a seguir?
Com a Wallet pronta para reagir aos eventos de ordem, o próximo passo é construir o trading-broker-order — o maestro que vai orquestrar o ciclo de vida completo de uma ordem:
- Receber a intenção de compra/venda via REST
- Validar o ticker na
trading-broker-asset - Salvar a ordem como
PENDINGe publicar no Kafka - Enviar a ordem ao Matching Engine via RabbitMQ
- Consumir o resultado e atualizar o status final
Quando esse serviço estiver pronto, teremos o primeiro fluxo ponta a ponta funcionando: do clique do usuário até o saldo atualizado na carteira.
Ficou com alguma dúvida sobre a lógica de custódia ou sobre o ciclo PENDING → FILLED → REJECTED? Deixe nos comentários!
🔎 Sobre a série
⬅️ Post Anterior: O Coração da B3: Construindo o Matching Engine com RabbitMQ e Redis
📘 Índice da Série: Guia da Série
Links:
Top comments (0)