
DefiStar: Building a Cross-Venue Perps Bot while Measuring Its Edge Away
TL;DR — DefiStar is a Rust monorepo, with its first crates running delta-neutral strategies across Hyperliquid and Kraken Futures with small amounts of money. This post is the development arc of these two initial main crates, reconstructed from the commit history: how a funding-arbitrage bot was wrongly measuring its own signal, what we found underneath it, and why the underlying mechanism is genuinely mean-reverting and still does not clear its own execution cost. I include the formulas used and implemented, the numbers, and the accounting of the two separate occasions where a metric reported a profit that was not there.
The shape of the workspace
Four strategy crates. Two of them are the subject of this post; the other two are new research lines.
| crate | market | status |
|---|---|---|
funding-arb |
Perp funding, Hyperliquid ↔ Kraken Futures | Live. Edge measured away, kept as a regime hedge |
basis-arb |
Cross-venue basis, same pair | Live. The real strategy. Signal proven, execution not |
coherence-arb |
Polymarket Dutch books | Shadow. Violations are real and lose money |
solana-arb |
Solana DEX microstructure | Closed by measurement. Median arbitrage = $0.02 |
The organizing thesis I followed from the beginning is that the edges available to one person on a Hetzner box are detection-and-modeling edges, not latency races. I am not going to beat a well-placed market maker.
Phase 1 — A funding bot that cannot see itself
Perpetual futures pay a periodic funding payment between longs and shorts to keep the perp pinned to spot, so when venue A pays longs and venue B pays shorts you go long A, short B, and collect the difference while staying delta-neutral.
The first implementation idea was that simple, and the strategy by itself did not go wrong, but the accounting did. The bot reported +$0.91 profit while the exchange balances were down about $3, because it was accruing what the model believed rather than what had actually settled. Rebuilding the reporting around venue truth meant reconciling every closed trade against the exchanges' own ledgers — fills, fees, funding — and printing n/a rather than guessing whenever a leg's ledger query failed. That part was pure plumbing. Nothing below it is knowable without it.
An instrument that kills the strategy
With honest accounting in place the next question was where the money actually came from. Realized PnL couldn't answer it: it nets funding and execution together, which hides the one number that decides whether the strategy works. So I split it into PnlBreakdown { hl/kraken × funding/execution }, with funding + execution == realized as a unit-tested invariant on both venues — HL funding from user_funding_history, Kraken by partitioning account-log rows.
On live trades, funding contributed ±0.4–3.6 bps while execution and basis movement contributed ±54–99 bps, which means the thing I was selecting on was twenty to a hundred times smaller than the thing determining the outcome. The bot's own hourly heartbeat had been printing the disproof the whole time, too: "Top 3 pairs by fee-adjusted bps/h" came back negative, −1.0 to −1.5, across all 147 markets, every hour, because HL↔Kraken liquid-alt funding spreads essentially never clear the ~1.5 bps/h fee floor at this tier. Across all five lifetime funding trades realized funding was 0.12–0.21 bps/h, so it cannot fund its own execution at any spread I have ever observed.
A trade that gave the game away
The bot opened a funding position on INJ, held it 24 hours and closed +$0.60 — its best trade to date — but the venue-truth breakdown showed +$0.03 of funding and +$0.57 of basis, so 95% of the profit came from something that isn't funding at all.
Then it opened a second INJ position and exposed the flaw, because funding decayed and went negative, meaning we were now paying it. The position should have closed but didn't, since the exit was watching the instantaneous funding rate the API reports — still positive — instead of the realized funding the ledger had actually paid. Two trades with one lesson: the strategy labelled "funding arbitrage" was, in this regime, a basis harvester wearing a funding-shaped hat.
Phase 2 — If the basis is the trade, is the basis tradeable?
The next move was to drop funding and trade the basis in both engines, but that would leave two perfectly correlated positions that die in the same conditions, and funding is compressed in this regime — a statement about now, not about the strategy. That gives the rule the whole workspace is built around:
The entry signal is regime-dependent. The execution tax is regime-independent.
Work spent making execution honest compounds across every future regime, while work spent re-fitting an entry signal to the current one is fitting to weather. So the next increment was continuous basis capture across every market, followed by an offline scan asking which markets have a harvestable cross-venue basis without hand-tuning a threshold to get there.
The decomposition and the trap it caught
Raw amplitude isn't enough, because two very different things produce a wide-looking basis: genuine mean-reverting dislocation, and cross-venue mark asynchrony, where the two venues simply stamp their marks at different instants. The second looks identical to the first in a histogram and is completely untradeable, but the shape of the autocorrelation decay separates them, since asynchrony noise spikes at lag 1 and collapses while real reversion decays geometrically.
Modelling the basis as a persistent AR(1) signal plus white noise,
the first two autocovariances are enough to recover everything that matters:
where is the fraction of variance that belongs to the persistent component and is the noise-stripped amplitude — the part of the swing you could actually harvest. Run over 18 markets across 12 hours:
| market | noise | signal p90 | half-life | verdict |
|---|---|---|---|---|
| DYDX | ~41 % | 10.0 bps | ~26 min | harvestable |
| BNB | ~18 % | 6.3 bps | ~69 min | harvestable (cleanest) |
| NEAR | ~32 % | 7.0 bps | ~139 min | harvestable (slow) |
| FARTCOIN | ~62 % | 4.9 bps | ~9 min | mostly noise, under fees |
| WLD | ~72 % | 3.9 bps | ~11 min | mostly noise |
Two findings came out of that table: the apparent one-minute half-life that had made the basis look untradeable was a noise artifact, and once the asynchrony is stripped the real signal persists 20–140 minutes, which is comfortably tradeable. And FARTCOIN, the market we had actually been trading, is over 60% noise.
A decision fell out of this and got logged: do not lower the liquidity floor, since thinner books amplify exactly the mark-asynchrony noise this analysis exists to fight while also worsening maker-fill slippage. Going down-market is a trap the measurement can see coming.
Phase 3 — Put the analysis in the runtime, then try to kill it
A static whitelist from a one-off scan is a snapshot of a market that moves, so the decomposition went into the bot: BasisStats streams EWMAs of the demeaned series' lag-1 and lag-2 cross-products and recomputes , , and every tick. A market is eligible only if it is warm and and noise and bps and minutes.
The gate behaves differently by mode, which matters more than the thresholds: in shadow it does not block, it tags. Every event carries gate_eligible alongside the metrics, so eligible and ineligible trades can be compared and the gate can be proven to select before it is ever trusted with money.
Then there is the bar that is easy to skip: a signal has to beat its own null, or it means nothing. So I replayed the identical strategy — same exit, same fees — under two entry policies, one entering only when and one entering every tick it was flat, and bootstrapped the confidence interval on the difference. If it excludes zero the stretch trigger has real skill; if it doesn't, the payoff is oscillation-harvesting available from any entry and the filter is decoration. I validated the test on synthetics before trusting it: the 2σ entry beat trade-always on a genuine OU process (+18 bps, CI excluding zero) and failed on a random walk. That second half is what makes it a test rather than a formality.
Phase 4 — Live, and the backtest that could not be negative
Armed with $50 positions, the first live window ran 32 hours and lost $1.19, with nine of fifteen opens failing before they became positions and the six that opened closing net-negative.
What had justified going live was a backtest showing +20.9 bps per round trip at maker-maker fills, so I repriced the identical 4,412 shadow round trips against the executable quotes that were actually on the book at those timestamps:
| execution path | bps/trade | $ over 4,412 |
|---|---|---|
| taker both legs | −27.8 | −$614 |
| HL maker + Kraken taker — what we ran | −13.8 | −$304 |
| maker both legs | +46.5 | +$1,027 |
The +20.9 became −13.8 — not smaller, inverted. The backtest had been computed in mid-price space, and you cannot trade at mid. An execution assumption that cannot fail is not a bound, it is a bug.
The more interesting question was why the gate kept selecting those particular markets, and the answer came back as a correlation between the median Kraken spread and the basis volatility of ρ = +0.53. The "best signal" markets — KAITO, MOODENG, SPX, GRASS — are the widest, stalest books, which means the stretch and the spread are the same object: the gate wasn't finding dislocation, it was finding illiquidity and calling it signal.
Fixing that required a qualitative change, turning cost from a prior into a floor:
Here is the wider of the two books recomputed per tick, and it sets the floor because it is what the router crosses when the maker leg times out, so no measurement is allowed to price an entry below it — the old code let a 126 bps blown-out Kraken spread cast a 30% vote in its own cost, and it opened, and it lost. bps is the HL-maker plus Kraken-taker round trip, and charges an entry for the dispersion of its own cost estimate, which matters because KAITO's measured drag oscillated 42 → 65 → 94 bps and the old gate opened on each dip. Re-backtested over the same nine days, the shipped gate admits 42 trades out of 5,110 at +39.0 bps each — selective rather than silent.
That gate also came with an inversion of the execution geometry, since the bot had been resting passively on Hyperliquid's tight book (where resting earns nothing) and crossing Kraken's wide one (where crossing costs everything). Resting the maker on the wide book alone and hedging the liquid one only on a confirmed fill prices the same 42 trades at +67.5 bps even under the hostile assumption that the maker earns no spread credit at all, and it makes a miss free: nothing is exposed while the order rests, so a no-fill costs nothing.
Phase 5 — The exit, and why we did not model a jump process
Since the basis mean-reverts, the exit is an optimal-stopping problem, and the natural model is Ornstein–Uhlenbeck:
In the continuation region the value of holding satisfies
and you exit at the first hit of an optimal boundary pinned by the usual pair of conditions,
which is the Bertram (2010) / Leung & Li (arXiv:1411.5062) route — fit the OU, read off and , plug in. Everything downstream of that depends on being both real and accurately estimated, since a wrong hands you a confidently wrong boundary, so before implementing any of it I went and tested the assumption underneath.
Per-tick basis increments have excess kurtosis +14.8 against a Gaussian's zero, because on a thin book the basis does not diffuse — it sits still and then jumps, when somebody finally crosses the spread. That falsifies the diffusion assumption the closed form rests on, and the tempting corollary is to rest the exit far out and catch the jumps, except the same dataset disproves that too: the empirical sits below a Brownian null at every level from 5 to 80 bps, because reversion suppresses excursions faster than jumps create them. That suppression is the cleanest evidence I have that — the line got its green light from the opposite direction to the one I was looking in.
Measure the boundary instead of fitting it
Rather than try to force a fit to a model, I measured the object that the boundary depends on. Resting a passive limit bps in your favour fills if and only if the path reaches , and it captures exactly — you fill at your own price and the overshoot belongs to the counterparty — so the expected capture and the boundary are simply
with the maximum favourable excursion within the holding horizon . Estimating from this market's own realized excursions, held as a decaying histogram so it re-prices as the regime shifts, puts jumps, fat tails, asymmetry and venue-specific weirdness into it by construction, and leaves no to get wrong.
Measured over 23 days of live basis across 38 markets, net of a 6 bps maker round trip at h:
| entry condition | net bps | |
|---|---|---|
| flat (any tick) | 10 | +0.65 |
| 1–2σ | 10 | +1.85 |
| 2–3σ | 15 | +2.63 |
| >3σ | 15 | +4.85 |
The monotonicity in stretch is what makes it a mechanism rather than a fitted curve, and it survives an out-of-sample split with the ordering preserved in both halves — flat entry decays to −0.05 while 2σ+ stays paid at +2.21. Random entry is a coin flip after fees, so all of the edge lives in when you enter and the passive exit is what banks it. One assumption remained, since the estimator counts a touch as a fill, but measured against 760k quote observations per venue the touch→fill haircut is only 1–4% at a 24-hour horizon, which the +2–4 bps survives comfortably.
The estimator has since been sharpened by noticing that is a hitting time: the instant the path crosses it has proved , and the remaining horizon cannot un-prove it. The original version only recorded a window once its full horizon had elapsed, which at 24 hours meant sitting on certain knowledge for a day. It now tracks reached[b] and resolved[b] per level and reports their ratio. An in-flight window is credited immediately at every level up to its running max, and counted in neither array above that — where it is right-censored and still at risk. Treating a "hasn't happened yet" as a "didn't happen" would bias every high level downward and drag toward zero, and since each window still contributes to any level at most once, this buys latency rather than fake sample size.
Phase 6 — So why is there no money?
The arithmetic that decides everything is not about the signal at all. That "+2–4 bps net" assumed 6 bps maker-maker fills, but the sequential open crosses the liquid leg as a taker, which is 13 bps round trip:
| entry stretch | gross | maker-maker | sequential (today) | taker-taker | |
|---|---|---|---|---|---|
| flat | 10 | 6.9 | +0.9 | −6.1 | −12.1 |
| 1–2σ | 10 | 7.9 | +1.9 | −5.2 | −11.2 |
| 2σ+ | 15 | 9.2 | +3.2 | −3.8 | −9.8 |
The edge exists only at maker-maker, every bucket is net negative at the execution actually running, and no stretch is big enough to rescue it because gross capture caps out around 9–11 bps. It was never risk-free arbitrage. It is cross-venue statistical arbitrage with execution risk, and it pays only when patient maker execution clears the fee hurdle.
Two measured facts sharpen that. Passive fills are toxic, and the toxicity grows with distance from the touch at −8 to −22 bps per 20 ticks, which is harmless for the exit (we want to be filled when the basis reaches our target) but real and expensive for a maker entry leg, since you are filled precisely when the other side knows something you don't. And the falsifier I had written down in advance — below a ~40% wide-venue fill rate, the floor isn't real — fired, coming in at ~15% over six days armed:
| stage | n |
|---|---|
| basis signals fired | 1,821 |
| passed the live entry gate | 13 |
| passive leg filled in its window | 2 |
| became a position | 0 |
Which brings us to the one picture that contains the whole post. The shadow mirror — the same trades priced at mid, paying nothing — returns a median of +12.9 bps across 2,746 round trips at a 96.6% win rate and a median hold of eleven minutes. It looks like a business, right up until you draw on top of it the cheapest round trip that can physically exist on this pair:
That floor is 12 bps of cross fee plus 2 bps of margin at a spread of exactly zero, which is not achievable — the real spread is always positive — and the median trade is already underwater against it, at −1.1 bps with only 43.9% of trades clearing. Push the assumption to a realistic 20 bps and the survivors drop to 24.4%. So the live gate rejecting 99.3% of signals is not a malfunction; it is the machinery correctly reporting that the edge does not clear its cost.
What would actually make this work
The signal doesn't need improving. It reverts on 96.6% of mirror round trips in about eleven minutes, it is monotone in entry stretch and it survives out-of-sample, so the gap is entirely execution and it is roughly 14 bps wide. Three things attack it directly.
The first is patient both-leg maker resting across the full hold horizon, force-crossing only at the max-hold deadline, because the 15% fill figure was measured on a 45-second window while the fill-vs-touch work suggests ~0.9 over a 24-hour hold — which is what would put us in the maker-maker column, the only positive one in the table above.
The second is a fix I shipped while writing this, and it turned out to be a worse bug than the one I was looking for. Chasing why 11 of 13 attempts never filled, I found that decide() only emits an entry when the tick carries an OPEN event and the tracker emits one per excursion, so the engine had exactly one attempt per opportunity and a miss discarded the whole excursion. A gate-approved entry is now a pending open, re-posted every tick and repriced against the fresh book until it fills, the stretch decays, or patience runs out — with the full cost gate re-run on every re-post, so a book that blows out while we wait stops the retry rather than being grandfathered in.
The third is to re-test at the horizon it was validated at, since every number quoted for the boundary came from h while the estimator was running at h, which makes the readings collected so far incomparable to the result that justified building it.
If patient maker execution lands the fill rate anywhere near the touch-based estimate, the same trades that are −3.8 bps today are +3.2 bps, and that is a measurable, falsifiable claim rather than a hope.
The other two crates, briefly
Two newer research lines, a few sessions each, both producing clean negatives worth the time. On coherence-arb, Dutch books on Polymarket are real and still lose money: the median gross edge before any fee is −90 bps, a genuine 1.15¢ violation on a Fed-decision event carried ~1.9¢ of taker fees, and deleting the fee entirely is worth ~343 bps while still landing 134 bps short of zero. On solana-arb, the median atomic arbitrage earns $0.02, the top decile is 96.3% of all profit, and a deployed multihop executor of exactly the architecture I had planned makes nothing.
Both landed where the perps line landed, which is the through-line of the whole post: the mechanism is real, it is observable, and it is worth cents.
The other through-line is the one that cost the most. Twice a number told me I was profitable — the +20.9 bps mid-price backtest above, and later a prediction-market "maker upper bound" that valued every leg at its best bid with no depth requirement, priced a 0.002/0.998 dust book as buying a $1 payout for 0.4¢, and duly reported 99.9% of all baskets as profitable while ranking the least tradeable books highest. A metric that fires on 99.9% of its inputs is measuring nothing, which is why every falsifiable claim now goes into a per-crate experiment ledger with its prediction and kill condition committed before the run and the verdict appended after, whether it flatters me or not.
DefiStar is a personal research project. Nothing here is financial advice, and the position sizes are deliberately too small to be interesting to anyone but me — it is just a small laboratory, and the tuition is capped.
If you build trading infrastructure and any of this sounds like a problem you have — venue-truth accounting, execution cost modelling, or just wanting a backtest that can come out negative — get in touch.
# related

DeFi Bots Series — Part 8: The Monitor Test Saga (Making the Monitor Debuggable)
I debugged the PnL, refactored the messy code we left implemented in our last devlog and made everything more compact and debuggable.

DeFi Bots Series — Part 7: The Monitor Test Saga (One-Sided Bids, Skew Rebalances, and Real PnL)
I stress-tested a one-sided, USDC-anchored LP strategy overnight: ~15 rebalances, lots of fee accrual, a few bugs, and a clearer picture of what to fix next. We tightened pool orientation, made SOL/WSOL funding sane, added skew gates (TVL/fees), and wrote proper lineage + flows. The monitor is quieter—until it needs not to be.

DeFi Bots Series — Part 6: Base-Funded Opens and Sweeps, Clean PnL, and a Quiet (Smarter) Monitor
I moved position funding and settlements to a USDC base, fixed a sneaky PnL bug (price/mint orientation), taught the monitor to chill (cooldown + “in-range = HOLD”), and battle-tested open/close scripts with ledgered flows. It’s finally… boring—in the good way.