The premise

Mean reversion is a one-line bet: when price is unusually far from its recent average, the next move is more likely to be back toward the average than further away. In equities the effect is small and slow; in crypto, with thinner books and faster sentiment cycles, it is large enough to trade: for the 60% of the time we are in a ranging regime.

The remaining 40% is the entire problem. During trending regimes, a mean-reversion bot does not lose mildly; it loses spectacularly, because the strategy compels it to keep buying into a falling knife. The whole job of an AI-native mean-reversion bot is to know which regime we are in and act accordingly.

The classical implementation

The textbook approach is the z-score: how many standard deviations is the current price from its rolling mean.

import numpy as np
import pandas as pd

def zscore(prices: pd.Series, window: int = 24) -> pd.Series:
    mean = prices.rolling(window).mean()
    std  = prices.rolling(window).std()
    return (prices - mean) / std

def signal(z: float) -> str:
    if z <= -2.0:  return "BUY"
    if z >=  2.0:  return "SELL"
    if abs(z) < 0.3: return "EXIT"
    return "HOLD"

Hourly bars, 24-hour window, two-sigma triggers. On a clean ranging market this is enough to be net-profitable after fees. On a trending market it is enough to be net-bankrupt.

Why the textbook version fails alone

Three failure modes recur:

  • Regime persistence. When BTC enters a sustained trend, the z-score saturates at ±3 to ±5 and stays there for days. The bot keeps buying the dip while the dip keeps dipping. By the time the trend ends, the position is too big to scale out without slippage that erases the eventual gains.
  • Volatility expansion. The standard deviation in the denominator rises during volatile periods. That makes the z-score look smaller than the price move feels: a 5% move during a calm week is z=4; the same move during a stormy week is z=1.5. The bot under-reacts to actual extremes when they matter most.
  • News-driven asymmetry. A z-score does not know that the Fed just cut rates 50bps. It treats the resulting price move as a mean-reverting opportunity instead of a structural break.

The AI-native version

Three additions turn the textbook strategy into a robust one. Each is the kind of decision an LLM makes well and a rolling indicator does poorly.

1. Regime classifier

Every hour, before evaluating the z-score, ask a frontier model: "given the last 7 days of price, the current funding rate, the last 24 hours of news headlines, and current on-chain flow, classify the regime as RANGING, TRENDING_UP, TRENDING_DOWN, or STRUCTURAL_BREAK." Pause the strategy in trending regimes. Disable it entirely during structural breaks.

async def classify_regime(symbol: str) -> str:
    ctx = {
        "prices_7d": fetch_ohlcv(symbol, "1h", 168),
        "funding_24h": fetch_funding_rate(symbol, 24),
        "news_24h": fetch_x_firehose(symbol, hours=24),
        "onchain_flow": fetch_glassnode_metric(symbol, "exchange_netflow", "24h"),
    }
    msg = await claude.messages.create(
        model="claude-sonnet-4-5", max_tokens=200,
        messages=[{"role": "user", "content": REGIME_PROMPT.format(**ctx)}],
    )
    return parse_regime(msg.content[0].text)

Don't run this on every tick: once an hour is fine. The cost is one Claude call per pair per hour; on 10 pairs, that is roughly $0.30 per day. The PnL impact is large.

2. Volatility-adaptive entry threshold

Instead of a fixed z=2 entry, scale by the ratio of recent realised volatility to baseline volatility:

def adaptive_threshold(realised: float, baseline: float) -> float:
    return 2.0 * (1 + 0.5 * (realised / baseline - 1))   # tighter in calm, looser in storm

Calm market: threshold drops toward 1.5σ, more entries. Stormy market: rises toward 3σ, fewer entries. This compensates for the standard-deviation-in-denominator problem above.

3. News-aware halt

The regime classifier already catches scheduled events; the news-aware halt catches surprises. A stream of X firehose + Reuters filtered by an LLM that classifies headlines as "high-impact for symbol X" pauses the bot for a configurable cooldown when the model flags something material. This costs almost nothing at inference but prevents the worst category of single-event blow-up.

The math, including all three additions

async def loop(symbol):
    while True:
        regime = await classify_regime(symbol)             # hourly
        if regime != "RANGING":
            await sleep(3600)
            continue

        prices = fetch_ohlcv(symbol, "1h", 100)
        z = zscore(prices.close).iloc[-1]
        thr = adaptive_threshold(realised_vol(prices, 12),
                                  realised_vol(prices, 168))

        if news_halt_active():
            await sleep(60)
            continue

        if z <= -thr and not in_position():   place_buy(symbol)
        if z >= +thr and not in_position():   place_sell(symbol)
        if abs(z) < 0.3 and in_position():    close_position(symbol)

        await sleep(60)

Roughly thirty additional lines on top of the classical strategy. The cost is a few Claude calls per hour per pair. The benefit is the difference between a strategy that is 55% profitable on the right markets and 30% across all conditions.

Risk management: the non-negotiable parts

  • Position sizing. Risk no more than 1% of capital per trade: measured as the distance between entry and stop, multiplied by position size. Fixed-dollar sizing is a beginner trap; vol-scaled sizing is the only honest answer.
  • Hard stops. Every entry has a stop at the price where you would be forced to admit the regime classifier was wrong. Typically 2.5–3× the entry-level z-score expressed in price terms.
  • Inventory caps. Across all pairs, hold no more than 3× capital in directional exposure. Mean-reversion looks decorrelated until a bad week proves it is not.
  • Drawdown circuit. Halt all trading on -3% intraday or -5% trailing-week. Restart manually. Auto-restart is how a single bad regime turns into a quarter of bad regimes.

Backtesting honestly

The most common backtest mistake is testing on the data the strategy was tuned on. Two practical rules:

  • Walk-forward. Tune parameters on Q1, test on Q2. Tune on Q1+Q2, test on Q3. Performance should not collapse on the test segments.
  • Slippage at full size. Backtests using top-of-book prices over-state PnL by 15–30% for thin pairs. Always price fills at the size you would actually trade, against historical book depth if you have it.

What this looks like deployed

The deployed version of this strategy is small in line count and large in dependencies: the regime classifier, news firehose, vol estimator, risk caps, and audit log are all separate concerns that have to compose cleanly.

In NickAI, the mean-reversion strategy is a graph: a Sense node (price + news + on-chain), a Reason node (regime classifier + z-score evaluator with multi-LLM consensus on regime), an Act node (risk-capped order placement on the user's exchange API), and an Audit node (every signal, every prompt, every fill). The same architecture works for any reversion-style strategy; the parameters change, the structure does not.