Skip to content
← Developer Diaries

Building a Rust Statistical Arbitrage Portfolio Bot

10 min read Revised
  • Systems
  • #rust
  • #trading
  • #statistical-arbitrage
  • #backtesting
  • #portfolio
  • #bybit

A pairs trading bot is not interesting because it computes a z-score. It gets interesting when you make multi-leg execution, research hygiene, and runtime honesty part of the strategy rather than cleanup work.

Most trading bot writeups stop at the signal. They explain the indicator, show an equity curve, maybe gesture at risk management, and then quietly skip the part that usually breaks the system in practice: state, execution, and false confidence.

That is the part I cared about in this project.

This repository is a Rust-native statistical arbitrage portfolio bot for Bybit testnet. On paper, the strategy is simple enough to explain in a sentence: model the spread between two related instruments, wait until that spread is abnormally wide or narrow relative to its own recent history, and bet on mean reversion. The signal logic is not the hard part. The hard part is turning that idea into a system that can answer unpleasant questions honestly.

Questions like these:

  • What happens if one leg fills and the other does not?
  • What happens if two pair strategies want the same symbol at the same time?
  • What happens after a restart when the exchange and the local journal disagree?
  • What happens when a parameter set looks brilliant on compounded returns and weak everywhere else?
  • What exactly is running live right now, and can the process prove it?

I wanted a bot that had a real answer to those questions, not one that quietly hoped they would not come up.

The strategy idea is small; the engineering problem is not#

Pairs trading sounds cleaner than directional trading because it shifts the bet from absolute price movement to relative mispricing. Instead of asking whether one market goes up or down, you ask whether two instruments have moved too far apart.

The spread here is modelled in log space:

  • spread = ln(price_a) - ln(price_b)

From there the system keeps a rolling mean and standard deviation, and turns the current spread into a z-score. A high positive z-score can justify a short-spread trade. A high negative z-score can justify a long-spread trade. If the spread mean-reverts, the position closes. If it keeps moving against the trade, the stop closes it instead. If it goes nowhere for too long, the time stop gets rid of it.

That part is standard. The part people underrate is what pairs trading does to the engineering surface area.

A single-symbol strategy can be wrong in one obvious way: it can call direction badly. A pairs strategy has more ways to fail.

It can fail because the relationship between the two symbols changed. It can fail because one leg filled and the other lagged. It can fail because a spread that looked stationary in one regime stopped behaving that way in the next. It can fail because two otherwise sensible strategies interfere with each other at the portfolio level. It can fail because the historical result was mostly a compounding artifact wearing a clever outfit.

So I did not want three disconnected bots with three disconnected sets of assumptions. I wanted one coordinated runtime that treated the portfolio as a portfolio.

Why I collapsed it into one runtime#

The final shape is one service: crypto-pairs.service.

That sounds like an operational detail, but it is really an architectural decision. Running one process means the portfolio gets a single place to make coordination decisions. That matters when strategies share risk budget, share account state, or could collide on symbols.

The runtime now has:

  • one active portfolio definition
  • one database-backed journal
  • one portfolio guard
  • one runtime snapshot for read-safe observability
  • one in-process alerting path

That is a better fit for pairs trading than the usual "spin up another worker" instinct.

The portfolio currently trades three non-overlapping pairs on testnet:

  • AAVEUSDT / ETHUSDT
  • ENAUSDT / XRPUSDT
  • BNBUSDT / XAUTUSDT

The non-overlap is deliberate. Shared symbols are where portfolio bots start lying to themselves. If one pair is already exposed to a symbol, another pair should not quietly open a conflicting trade because it happens to live in another process with another little universe of state. The portfolio guard exists to stop that kind of nonsense before it becomes "unexpected behaviour" in a status page.

Rust did not improve the alpha. It improved the honesty.#

I like Rust for trading systems for a boring reason: it makes it harder to be vague.

Moving this bot to Rust did not create an edge. It did not make the spread more stationary or the z-score more predictive. What it did was force the runtime boundaries to get sharper.

The codebase now separates research, execution, persistence, reporting, and runtime coordination much more cleanly than the earlier sprawl did. That matters because in trading infrastructure, a lot of bad systems survive by being unclear about where decisions come from and where state actually lives.

If a journal entry says a pair is open, I want to know where that came from. If the process thinks a position is flat, I want that claim tied to something inspectable. If the service restarts, I want to know whether the position state survives the restart and whether the runtime can reconcile itself with the exchange without pretending everything is fine.

Rust is useful here because it rewards explicit state transitions and punishes sloppy edges. For this kind of system, that is worth more than shaving a little latency off a strategy that is not high frequency anyway.

Research quality matters more than a pretty backtest#

This was the second big change in the project: I stopped treating parameter selection like a beauty contest.

The worst thing a trading repo can do is make overfitting look like progress. That usually happens when one metric takes over the conversation. Net return. Profit factor. Win rate. Pick your poison.

Those numbers are not useless, but they are easy to flatter.

A parameter set can look great because it caught one unusually good segment of history. It can look great because compounding blew up a small edge into a cartoon equity curve. It can look great because the holdout sample is tiny enough that one or two trades dominate the impression. It can look great because out-of-sample losses never arrived yet, which produces infinite profit factor and convinces people they found a law of nature.

I wanted the repo to resist that temptation structurally.

So the research path now evaluates candidates through several different lenses:

  • train, holdout, and full-sample separation
  • risk-sized and fixed-notional comparisons
  • local neighborhood audits around the current parameters
  • walk-forward selection over rolling in-sample and out-of-sample folds
  • an explicit promotion gate that classifies candidates as promote, watchlist, or reject

That last part matters. A lot of trading work falls apart because every candidate is presented as a ranked list, which quietly suggests the top item should be shipped. But the top item in a weak field is still weak. A promotion gate forces a more honest conclusion: some candidates are worth promoting, some are interesting but under-evidenced, and some should die even if they have one flashy number.

That is how the current live testnet parameter set ended up where it did. Two pairs earned promotion-level changes. One did not. The system now says that explicitly instead of flattering every improvement pass into a rollout.

Two-leg execution is where the strategy gets real#

A spread trade is not one order. It is two orders that need to behave like one position.

That sounds obvious until you see how many toy systems ignore it.

If one leg opens and the other fails, you do not have a pair trade. You have orphan exposure. If an unwind has to happen, it needs its own logic. If the exchange is slow or inconsistent about order state, the runtime cannot just wave that away because the signal looked good when it was backtested on candles.

This bot is built around the assumption that legging risk is normal, not exceptional. That is why the runtime uses limit orders only, has explicit unwind handling, persists state in the journal, and treats reconciliation as a first-class concern. A nice backtest with naive fill assumptions is not enough. The strategy has to survive contact with the uglier execution paths too.

That is also why I care about what the bot can prove after the fact.

One service, no cron noise#

I removed the pile of background cron jobs around this project and pushed alerting into the bot itself.

The setup now is lean on purpose:

  • one running service
  • zero cron monitor jobs
  • Telegram alerts only for real trade lifecycle events
  • status and dashboard generation on demand from persisted state and exchange readback

That is closer to how I think these systems should feel. If the only way a bot looks observable is by surrounding it with six helper processes, then the bot is not actually observable. It is being babysat.

Now the runtime writes a snapshot, persists its state, reports its loaded parameters, and emits alerts directly when something real happens. The status tool can show both the config-layer view and the runtime-layer view of the active parameters. The service journal logs the parameters it loaded at startup. That means the process can answer the basic operator question cleanly: what are you actually running right now?

I do not think that is a luxury feature. I think it is table stakes.

What is live right now#

The current live environment is still testnet, which is exactly where it should be.

The running portfolio is small and intentionally conservative in scope:

  • AAVEUSDT / ETHUSDT with a 210-bar window, 3.0 entry z, 4.5 stop z, 0.0 target z, 48-bar max hold
  • ENAUSDT / XRPUSDT with a 336-bar window, 3.25 entry z, 4.5 stop z, -0.5 target z, 120-bar max hold
  • BNBUSDT / XAUTUSDT with a 200-bar window, 3.0 entry z, 4.75 stop z, -4.5 target z, 72-bar max hold

All three run at a 3% risk budget in the current testnet configuration.

That does not mean I trust the edge blindly. It means I trust this setup enough to keep learning from it without pretending the learning phase is over.

What I still do not trust#

Any honest trading writeup should have this section.

I do not trust a testnet result the way I would trust long live execution history. I do not trust a strong backtest just because it is strong. I do not trust a pair relationship to stay stable because it was stable once. I do not trust an execution path until I have seen it behave through the ugly cases.

The unresolved risks are the standard ones for this class of system:

  • slippage
  • funding behaviour that matters more live than it did historically
  • degraded mean reversion in a new regime
  • partial fills in the wrong places
  • execution asymmetry during volatility
  • parameter decay

That is fine. A serious project does not need fake certainty. It needs a clear picture of what has been proven, what has not, and where the system is allowed to be skeptical of itself.

Why this project matters to me#

There are thousands of bots that can calculate a z-score.

What interests me is building one that can survive the questions that come after the z-score. Not just whether it trades, but whether it can explain itself. Not just whether it backtests, but whether the research path filters out flattering lies. Not just whether it runs, but whether it runs with a footprint small enough that every moving part still feels justified.

That is the version of trading software I care about: not louder, not more magical, just more honest.

And if the edge eventually disappears, I still want the system around it to have been built properly. That part compounds even when the strategy does not.

Comments

Sign in to comment. No password required.

Loading comments…