Skip to content

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 / risk

Core 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 >200

Rounding 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

  1. Threshold 0.02 hardcoded: not exposed via the API, not adjustable from Canvas
  2. Default .buy() size = 1 share: total_return barely reflects alpha (current-state lock; same gotcha as momentum)
  3. slow_period silently ignored (kept for UI parity)
  4. Long-only: deviation > +2% only closes, never shorts
  5. No entries during the SMA warmup (first fast_period-1 bars)
  6. Sharpe without risk-free deduction
  7. Max drawdown peak-trough only
  8. Equity-curve downsample edge case: bars ∈ (200, 400] → step = 1 → no downsampling
  9. yfinance auto_adjust=True fixed
  10. 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-identical
  • result_structure — 13 fixed top-level keys
  • rounding_contract — metrics round 4, equity_curve round 6
  • trades_executed — oscillating data must trigger ≥1 round-trip
  • win_rate_bounds — ∈ [0, 1]
  • flat_market_no_trades — flat (< 2% swing) → 0 trades, return 0
  • fetch_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)

Verifiable intelligence for the decisions that demand scrutiny.