What (One-liner)
Single-name mean-reversion backtest: buys/sells trigger when price deviates from SMA(fast_period) beyond ±2%. Backtrader event-driven + real yfinance history + 10bps commission. Returns total_return / Sharpe / max_drawdown / win_rate / normalized equity curve.
How to use
Drag mean_reversion_strategy_v1 onto the Canvas, wire an upstream price_factor_v1 node (or fill symbol) + optional fast_period=20 (SMA window). Date range defaults to the last 365 days.
Typical wiring:
upstream price_factor → mean_reversion_strategy_v1 (fast_period=20) → portfolio / riskCore formulas
# Strategy (Backtrader event-driven):
deviation = (close - SMA(fast_period)) / SMA(fast_period)
if deviation < -0.02 and flat: .buy() # HARDCODED threshold
if deviation > +0.02 and long: .sell()
# Post-run metrics (same pipeline as momentum):
total_return = (final - 100k) / 100k [round 4]
annualized_return = (1 + tr) ** (252/bars) - 1 [round 4]
sharpe_ratio = mean(daily)/std * √252 [round 4]
max_drawdown = max((peak - eq) / peak) [round 4]
win_rate = won/total on exits [round 4]
equity_curve = round(v/100k, 6), downsample step=len//200 if >200Rounding contract: metrics → 4; equity_curve → 6.
Assumptions & applicability
Assumptions: daily + ≥30 bars + long-only + $100k capital + 10bps commission + default .buy() size=1 share + no slippage + fixed 0.02 threshold + yfinance auto_adjust=True.
Fits: single-name mean-reversion backtests in range-bound markets, Canvas strategy-graph overlays, post-institution-decision direction adjustment, teaching demos.
Does not fit: trending markets (structural loser), intraday / shorting / multi-name pairs / custom-threshold production / crisis periods.
Input / Output contract
{symbol?, start_date?, end_date?, fast_period?=20, slow_period?=30 (unused), data_source?='yfinance'} → {symbol, strategy, start_date, end_date, total_return, annualized_return, sharpe_ratio, max_drawdown, win_rate, num_trades, equity_curve, bars, data_source, error?}
Canvas additionally wraps signal (buy/sell/neutral), fast_ma, slow_ma.
Known limitations
- Threshold 0.02 hardcoded: not exposed via the API, not adjustable from Canvas
- Default .buy() size = 1 share: total_return barely reflects alpha (current-state lock; same gotcha as momentum)
slow_periodsilently ignored (kept for UI parity)- Long-only: deviation > +2% only closes, never shorts
- No entries during the SMA warmup (first fast_period-1 bars)
- Sharpe without risk-free deduction
- Max drawdown peak-trough only
- Equity-curve downsample edge case: bars ∈ (200, 400] → step = 1 → no downsampling
- yfinance auto_adjust=True fixed
- akshare only for symbol='GOLD'
References
Poterba-Summers (1988) mean reversion in prices + De Bondt-Thaler (1985) overreaction + Avellaneda-Lee (2010) modern stat-arb. Full citations in the frontmatter.
Golden Test
tests/golden/fixtures/tier2/mean_reversion_strategy/ — monkey-patched fetch_price_data + 300-day oscillating OHLC (seed=7, 3% sine amplitude / 25-day period), 1e-10 tolerance, 8 tests:
matches_snapshot— full output byte-equal (11 trades, win_rate 1.0 in the synthetic scenario)determinism— 5 reruns byte-identicalresult_structure— 13 fixed top-level keysrounding_contract— metrics round 4, equity_curve round 6trades_executed— oscillating data must trigger ≥1 round-tripwin_rate_bounds— ∈ [0, 1]flat_market_no_trades— flat (< 2% swing) → 0 trades, return 0fetch_error_handled— data-fetch failure → error field set
Note: the fixture's 100% win_rate is a synthetic-oscillation artefact and does not represent real-market mean-reversion performance (~0.4-0.6 in practice).
Changelog
- 1.0.0 (2026-04-20) — First Active (Tier 2 batch 7)

