Hello everyone!
Continuing the My Broker B3 series, today we'll talk about an essential component on the B3 side of our simulator: the B3 Market Sync API.
Before building the engine that decides whether an order gets executed or rejected, we need to ensure it has access to real market prices. That's exactly what b3-market-sync-api does: fetches real asset prices from brapi.dev and stores them in Redis, ready for real-time consumption.
🏗️ What is the Market Sync?
This microservice has a single, well-defined responsibility: to be the market data producer of the ecosystem. It operates completely asynchronously and independently — no other service needs to call it directly.
The flow is simple and elegant:
[brapi.dev] ◀── Feign Client ── [MarketSyncScheduler]
│
market:price:{TICKER}
│
[Redis]
│
[B3 Matching Engine] (next post)
🎯 MVP Focus
The goal here is clear: have real prices available in Redis with low latency. The Matching Engine we'll build in the next post won't query a database or make HTTP calls to get prices — it goes straight to Redis. This ensures the matching decision happens in microseconds.
In this phase, I prioritized:
- Scheduled sync with market hours guard
- Redis cache with TTL to prevent stale data
- External API rate limit protection
- Critical bug fixes found during code review
- REST API for querying cached prices
- Swagger documentation
🛠️ Tech Stack
| Technology | Usage |
|---|---|
| Java 21 + Spring Boot 3.5.11 | Service core |
| Spring Cloud OpenFeign | Declarative HTTP client for brapi.dev |
| Spring Data Redis | High-performance cache |
| Spring Scheduling | Periodic synchronization |
| Jackson JSR310 | Java 8 Date/Time serialization support |
| SpringDoc OpenAPI | Swagger UI documentation |
🏗️ Implementation Pillars
1. Feign Client — Consuming Brapi
Spring Cloud OpenFeign lets you declare an HTTP client as a simple interface, with no boilerplate code:
@FeignClient(name = "brapiClient", url = "${app.brapi.url}")
public interface BrapiClient {
@GetMapping("/quote/{ticker}")
BrapiResponseDTO getQuote(
@PathVariable String ticker,
@RequestParam String token
);
}
The URL and token are injected via application.yaml, keeping the code clean and environment-configurable.
2. Scheduler with Market Hours Guard
The heart of the service is a scheduled job that fires every 30 minutes. But before any API call, it checks whether the market is open:
@Scheduled(fixedRateString = "${app.sync.interval:1800000}")
public void sync() {
if (!isMarketOpen()) {
log.info("Sync aborted: market is closed (outside trading hours or weekend).");
return;
}
// ... fetch and store prices
}
private boolean isMarketOpen() {
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/Sao_Paulo"));
DayOfWeek day = now.getDayOfWeek();
int hour = now.getHour();
if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) {
return false;
}
// B3 trading hours: 10:00 to 18:00 (Sao Paulo time)
return hour >= 10 && hour < 18;
}
Important detail: we use ZonedDateTime with America/Sao_Paulo explicitly. Inside a Docker container, the JVM may default to UTC — which would make the market hours guard behave incorrectly. We also pass -Duser.timezone=America/Sao_Paulo in the Dockerfile ENTRYPOINT to ensure the JVM honors the correct timezone:
ENTRYPOINT ["java", "-Duser.timezone=America/Sao_Paulo", "-jar", "app.jar"]
3. Redis Cache with Correct TTL
The MarketDataService stores each quote in Redis with a standardized key and 5-minute TTL:
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
public void saveToCache(BrapiResultDTO quote) {
String key = CACHE_KEY_PREFIX + quote.getTicker();
// TTL of 5 minutes — if sync stops, stale prices expire quickly
// preventing the Matching Engine from using outdated data
redisTemplate.opsForValue().set(key, quote, CACHE_TTL);
log.info("Ticker {} updated in cache: R$ {}", quote.getTicker(), quote.getRegularMarketPrice());
}
The TTL is fundamental: if the service goes down, prices expire in 5 minutes. The Matching Engine then fails to find the price in Redis and rejects orders — correct and predictable behavior, much better than using hours-old prices.
4. Epoch Timestamp Deserialization
A subtle bug I found: brapi.dev returns the regularMarketTime field as a Unix epoch (integer), but Jackson was trying to deserialize it as LocalDateTime (ISO string). This causes silent deserialization failure.
The solution was a custom deserializer:
public class EpochToLocalDateTimeDeserializer extends StdDeserializer<LocalDateTime> {
private static final ZoneId SAO_PAULO = ZoneId.of("America/Sao_Paulo");
@Override
public LocalDateTime deserialize(JsonParser parser, DeserializationContext context)
throws IOException {
long epoch = parser.getLongValue();
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epoch), SAO_PAULO);
}
}
And annotating it on the DTO:
@JsonDeserialize(using = EpochToLocalDateTimeDeserializer.class)
private LocalDateTime regularMarketTime;
5. Rate Limit Protection
Since we use the brapi.dev free plan, we added a delay between calls to avoid throttling:
for (String ticker : brapiProperties.getTickers()) {
try {
BrapiResponseDTO response = brapiClient.getQuote(ticker.trim(), brapiProperties.getToken());
if (nonNull(response.getResults()) && !response.getResults().isEmpty()) {
marketDataService.saveToCache(response.getResults().getFirst());
}
// Small delay between calls to avoid hitting rate limit on free plan
Thread.sleep(200);
} catch (Exception e) {
log.error("Failed to sync ticker {}: {}", ticker, e.getMessage());
}
}
The per-ticker try/catch is essential — if one asset fails, the loop continues for the rest.
6. REST API + Swagger
Although the Matching Engine consumes prices directly from Redis, we added REST endpoints to make debugging and observability easier:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/quotes |
List prices for all configured tickers |
| GET | /api/v1/quotes/{ticker} |
Get price for a specific ticker |
Documented via Swagger UI at http://localhost:8096/swagger-ui.html.
🔒 Security — Token Out of Code
A critical issue I fixed: the brapi.dev token was hardcoded as a default value in application.yaml:
# ❌ Before — token exposed in public repository
token: ${BRAPI_TOKEN:mFk3HMLijAtxsP5y1ZjhsY}
# ✅ After — no fallback, variable is required
token: ${BRAPI_TOKEN}
Never expose credentials as default values in public repositories. The application now fails on startup if the variable is not provided — correct behavior.
✅ Validating the Execution
With the application running locally:
- ✅ Tomcat started on port
8096 - ✅ Application initialized in under 3 seconds
- ✅ Scheduler fired and logged:
Sync aborted: market is closed (outside trading hours or weekend) - ✅ Swagger UI accessible at
http://localhost:8096/swagger-ui.html
🚀 What's Next?
With b3-market-sync-api up and feeding Redis with real prices, we have the foundation the next service needs to work. In the next post we'll build the B3 Matching Engine — the engine that will consume these prices and decide whether the broker's orders are executed or rejected.
Got any questions about the Redis integration or the market hours guard? Drop them in the comments!
🔎 About the Series
⬅️ Previous Post: From Stream to Database: Processing Market Data with Spring Boot, Redis and Flyway
➡️ Next Post: The Heart of B3: Building the Matching Engine with RabbitMQ and Redis (coming soon)
📘 Series Index: Series Roadmap
Links:
Top comments (0)