Investing · 2026.09.11 · 7 min · Updated 2026.10.26
Bitfinex Lending's Hidden Clock: A Batch Auction Every 20 Minutes
Bitfinex 2-day funding is really a batch auction clearing every 20 minutes; here's how I found it in the data, where my new quoting strategy earns in backtests, and the pitfalls of a three-step rollout.
Reading
My Bitfinex lending bot was getting lower rates than the ones on the board. The reason is how funding gets matched: 2-day funding is a batch auction that clears every 20 minutes on a fixed schedule, and the official docs don't mention it.
The bot's rates were low partly because its cancel timing didn't line up with this clock, and partly because of a floor it had set for itself.
The quoting strategy I rewrote around this finding gained about one percentage point over a year of backtesting. But the devil's-advocate round showed the money comes from raising the quote toward recent top prices; batch alignment itself contributes almost nothing.
The new strategy went out in three steps (data layer, switch, default), and each step can be rolled back on its own.
Board rates my bot couldn't get
For a while I kept seeing fills at a daily rate of 0.0003 (about 11% annualized) in the trade history, while my bot was getting roughly 6.6% annualized over the same period.
I had two guesses. Either high-rate orders had some queueing mechanism I couldn't get into, or the bot polled so often that it canceled its own offers before they had a chance to fill.
To answer that with data, I ran three rounds of research with Claude's multi-agent workflow, and the last round was dedicated to playing devil's advocate.
The market data was Bitfinex's public 1-minute funding candles going back a year. The fill model was calibrated against the bot's own real orders. Interest was computed over how long each loan actually stayed open, because 2-day loans are often repaid within a few hours.
Two clocks that don't match
For fUSD 2-day, the vast majority of filled notional is concentrated in these 20-minute batches, in every month of the year, and fUST (the USDT funding market) looks the same.
Each batch sweeps up from the lowest ask until that batch's borrowing demand is used up. So the fills within one batch spread out from low to high, and the top step is only a small share.
Illustrative; bar heights are not actual volumes.
I call the old strategy ladder: if an offer doesn't fill within a fixed time, cancel it, drop one step, and repost.
That timer has nothing to do with the batch clock, and more than half of the canceled offers were marked down without ever meeting a single batch.
Offers that met the next batch soon after being posted filled about three times as often as ones that had to wait a long time for their turn.
Polling frequency was fine. The cancel timing was the problem.

As for 0.0003, the 6.6% I was getting was really the bot's own floor. The ladder walks all the way down and stops at a discounted FRR (Flash Return Rate, the average of fixed-rate funding over the last hour).
The 0.0003 I'd been seeing was almost entirely 120-day fills plus the displayed FRR value. Less than 1% of 2-day volume filled at or above that price.
Queueing wasn't the issue either. Nearly all of our fills were ones the clearing price swept straight through.
Batch top: the yardstick, not the clock
The new strategy is called batch top. It takes the clearing top prices of the last few batches and applies a discount for the main quote. The floor is the median of recent fills, and the cap is a multiple of the long-term VWAP.
It only cancels and reposts when a new batch has printed, this offer was actually tested by that batch, and the gap between the old and new price is large enough. The tenor is fixed at 2 days.
Batches are detected from fill volume instead of hard-coded times, so if Bitfinex ever changes the schedule, the volume-based version will follow on its own. I'm keeping the exact discount and thresholds private.

Against a faithful replica of the current strategy, the backtest gained about one percentage point of annualized return over a year, and about half a point for fUST. The numbers hold under a strict fill model too.
That control has to be a faithful replica. If it can't be trusted, every bp you compute is meaningless.
But against the cheapest alternatives (a longer cancel timer, or always quoting at a high percentile of recent fills), the gap shrinks to a few dozen bp.
The devil's-advocate round didn't overturn the conclusion, but it narrowed where it applies quite a bit:
- In practice, the new strategy just raises the quote to near the top of the last few hours of fills and then discounts it. Batch alignment itself contributes roughly zero to annualized return; the money is in the yardstick, not the clock.
- Thirty to fifty percent of fills land at the floor price, and exposure to the top step is twice what the old strategy had.
- If every config quotes the same price, they stack on the same step and compete. Once capital scales up, this needs price jitter or a capital cap per price level.
Correcting "batch alignment is great" to "the money is in the yardstick" directly decided what to optimize next.
There were too few samples for an A/B test: the minimum detectable effect was about as large as the gap I wanted to verify. So acceptance became a before-and-after comparison around launch.
Three steps, each reversible
Step one was a dark ship of the data layer: the code goes live, but nothing uses its output yet.
A recorder periodically writes the public candles to the DB, only for minutes that have already settled, since the numbers for an unfinished minute can still change. A separate service computes candidate prices. If there isn't enough data or the batch structure looks wrong, it trips a breaker and falls back to a conservative quote.
At this stage nothing reads the candidate prices, and not a single byte of the live quoting changed.
Step two was the switch, which is just a column on the config:
UPDATE lending_configs SET quote_mode = 'batch_top' WHERE id = :config_id;
The column defaults to ladder, and the column itself is the switch. I didn't add an environment variable as a master switch on top of it; one more env var is just one more thing to fall out of sync.
Of the majors the code review caught at this step, the one most worth remembering was post-batch re-evaluation logic that reached into 120-day offers. The new strategy should only manage its own offers, but "which offers are mine" is easy to define too broadly.
Step three was making it the default. The day after launch I switched every config over.
With few users and little capital, testing live got answers faster than waiting. The worst case was bounded by capital caps, and switching back was still a single SQL statement.
The night of the switch, the pricing chart in Grafana disappeared entirely.
Those gauges were only emitted at the end of the ladder pricing path, and batch_top branches off at the entry, so it never reaches that line. Metrics from the old path don't error. They just freeze at their last value, then turn into No data after a restart.
The fix was to overlay batch_top's quote yardstick on the same chart and add a chart for breaker trip reasons. When adding a strategy branch, check at the same time whether any metric the dashboard relies on will go dark.
Looking back, the biggest win was a structure you can only see in the data: ask how the market actually matches orders before deciding how to quote.
batch_top only handles 2-day. What about longer tenors? Backtesting longer Bitfinex lending tenors covers how I backtested 7-day, 30-day, and 120-day lending, and which two lanes I kept for a trial run.

Engineering
From a FastAPI Script to a Spring Boot SaaS: Rebuilding My Bitfinex Auto-Lending Platform
A Bitfinex auto-lending side project rewritten from a good-enough FastAPI script into a multi-module Spring Boot and Next.js platform with idempotency, reconciliation, circuit breaking, and full monitoring.
Letters
Loading comments…