
Vectorbt
- 216 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
vectorbt is a Claude Code skill that runs vectorized backtests of trading strategies with parameter optimization and 50+ performance metrics using the vectorbt Python library.
About
A Claude Code skill for backtesting trading strategies with the vectorbt Python library. It expresses strategies as boolean entry/exit signal arrays, simulates portfolios with fees and slippage, and computes 50+ performance metrics, and it runs parameter grid searches across thousands of combinations quickly. Developers use it to validate and optimize strategies before risking capital.
- Vectorized backtesting 100-1000x faster than event-driven frameworks
- Parameter grid search across thousands of combinations in seconds
- 50+ built-in performance metrics (Sharpe, Sortino, Calmar, max drawdown)
Vectorbt by the numbers
- 216 all-time installs (skills.sh)
- Ranked #443 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
vectorbt capabilities & compatibility
Free; uses the open-source vectorbt library. Optional Yahoo Finance or Birdeye data feeds
- Capabilities
- backtesting · walk forward validation · parameter optimization · portfolio simulation · performance metrics
- Use cases
- data analysis · trading
- Pricing
- Free
What vectorbt says it does
vectorbt is a Python library for **vectorized backtesting** — running strategy simulations using NumPy/pandas array operations instead of bar-by-bar loops.
50+ built-in performance metrics (Sharpe, Sortino, Calmar, max drawdown, profit factor)
This creates 80 parameter combinations automatically
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill vectorbtAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 216 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Backtest and optimize a trading strategy over many parameter combinations using vectorbt in Python.
Who is it for?
Fast backtesting and parameter optimization of signal-based trading strategies.
Skip if: Live trade execution or tick-level event-driven simulation.
When should I use this skill?
You need to backtest a strategy or grid-search parameters quickly.
What you get
Backtest results with performance metrics and an optimized parameter set.
- A backtest with performance metrics and an optimized parameter grid
By the numbers
- 100-1000x faster than event-driven frameworks
- 50+ built-in performance metrics
Files
Vectorized Backtesting with vectorbt
Overview
vectorbt is a Python library for vectorized backtesting — running strategy simulations using NumPy/pandas array operations instead of bar-by-bar loops. This makes it 100–1000x faster than event-driven frameworks (backtrader, zipline), enabling parameter optimization across thousands of combinations in seconds.
Key strengths:
- Blazing speed via NumPy vectorization
- Built-in parameter grid search and optimization
- 50+ built-in performance metrics (Sharpe, Sortino, Calmar, max drawdown, profit factor)
- Rich plotting (equity curves, drawdowns, trade markers, heatmaps)
- Native pandas integration — your data stays in DataFrames throughout
Installation
uv pip install vectorbt pandas numpyvectorbt pulls in pandas, NumPy, and Plotly automatically. For technical indicators, also install pandas-ta:
uv pip install vectorbt pandas-taCore Concepts
1. Signals — Boolean Entry/Exit Arrays
Strategies in vectorbt are expressed as boolean pandas Series (or arrays) indicating where to enter and exit positions:
import vectorbt as vbt
import pandas as pd
# Entry: buy when fast EMA crosses above slow EMA
entries = fast_ema > slow_ema
# Exit: sell when fast EMA crosses below slow EMA
exits = fast_ema < slow_emavectorbt resolves conflicting signals automatically (you can't enter while already in a position).
2. Portfolio — The Backtesting Engine
vbt.Portfolio.from_signals() is the primary backtesting function. It takes price data and entry/exit signals, simulates trades, and computes performance:
pf = vbt.Portfolio.from_signals(
close=close_prices,
entries=entries,
exits=exits,
init_cash=10_000,
fees=0.003, # 0.3% per trade
slippage=0.005, # 0.5% slippage
freq="1h", # hourly data
)3. Metrics — Built-in Performance Analysis
# Full stats summary
print(pf.stats())
# Individual metrics
print(f"Total Return: {pf.total_return():.2%}")
print(f"Sharpe Ratio: {pf.sharpe_ratio():.3f}")
print(f"Max Drawdown: {pf.max_drawdown():.2%}")
print(f"Win Rate: {pf.trades.win_rate():.2%}")4. Parameter Optimization — Grid Search in Seconds
Pass arrays instead of scalars to test many parameter combos simultaneously:
import numpy as np
fast_periods = np.arange(5, 25, 2) # 10 values
slow_periods = np.arange(20, 60, 5) # 8 values
fast_ma = vbt.MA.run(close, fast_periods, short_name="fast")
slow_ma = vbt.MA.run(close, slow_periods, short_name="slow")
# This creates 80 parameter combinations automatically
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)Basic Workflow
Step 1: Load OHLCV Data
import pandas as pd
# From CSV
df = pd.read_csv("ohlcv.csv", parse_dates=["timestamp"], index_col="timestamp")
close = df["close"]
# From Yahoo Finance (traditional markets)
btc = vbt.YFData.download("BTC-USD", start="2023-01-01", end="2025-01-01")
close = btc.get("Close")For Solana tokens, fetch data via the birdeye-api skill and load into a DataFrame.
Step 2: Compute Indicators
import pandas_ta as ta
# Using pandas-ta (see pandas-ta skill)
df.ta.ema(length=12, append=True)
df.ta.ema(length=26, append=True)
df.ta.rsi(length=14, append=True)
df.ta.bbands(length=20, std=2, append=True)
# Or using vectorbt built-ins
rsi = vbt.RSI.run(close, window=14)
bbands = vbt.BBANDS.run(close, window=20, alpha=2)Step 3: Generate Entry/Exit Signals
# EMA crossover
entries = df["EMA_12"] > df["EMA_26"]
exits = df["EMA_12"] < df["EMA_26"]
# RSI mean reversion
entries = rsi.rsi_below(30)
exits = rsi.rsi_above(70)Step 4: Run Backtest
pf = vbt.Portfolio.from_signals(
close=close,
entries=entries,
exits=exits,
init_cash=10_000,
fees=0.003,
slippage=0.005,
size=0.95, # use 95% of available cash
size_type="percent",
freq="1h",
)Step 5: Analyze Results
# Summary statistics
print(pf.stats())
# Trade-level analysis
trades = pf.trades.records_readable
print(f"\nTrade count: {len(trades)}")
print(f"Avg holding period: {trades['Duration'].mean()}")
# Equity curve
pf.plot().show()
# Drawdown chart
pf.drawdowns.plot().show()Key Portfolio Parameters
| Parameter | Description | Example |
|---|---|---|
close | Price series (pd.Series or DataFrame) | df["close"] |
entries | Boolean entry signals | fast > slow |
exits | Boolean exit signals | fast < slow |
init_cash | Starting capital | 10_000 |
fees | Fee per trade (fraction) | 0.003 (0.3%) |
slippage | Slippage per trade (fraction) | 0.005 (0.5%) |
size | Position size | 0.95 |
size_type | How to interpret size | "percent", "amount", "value" |
freq | Data frequency | "1h", "4h", "1d" |
direction | Trade direction | "both", "longonly", "shortonly" |
accumulate | Allow adding to positions | False |
sl_stop | Stop-loss level (fraction) | 0.05 (5%) |
tp_stop | Take-profit level (fraction) | 0.10 (10%) |
Performance Metrics
Returns
total_return()— cumulative return over the periodannualized_return()— annualized compound returndaily_returns()— Series of daily returns
Risk
max_drawdown()— maximum peak-to-trough declineannualized_volatility()— annualized standard deviation of returnsvalue_at_risk()— VaR at specified confidence level
Risk-Adjusted
sharpe_ratio()— excess return per unit volatilitysortino_ratio()— excess return per unit downside deviationcalmar_ratio()— annualized return / max drawdownomega_ratio()— probability-weighted gain/loss ratio
Trade Statistics
trades.win_rate()— fraction of profitable tradestrades.profit_factor()— gross profit / gross losstrades.expectancy()— average P&L per tradetrades.avg_winning_trade()— mean profit on winnerstrades.avg_losing_trade()— mean loss on loserstrades.count()— total number of completed trades
Parameter Optimization
Grid Search
fast_windows = [5, 8, 12, 15, 20]
slow_windows = [20, 26, 30, 40, 50]
# Run all 25 combos at once
fast_ma = vbt.MA.run(close, fast_windows, short_name="fast")
slow_ma = vbt.MA.run(close, slow_windows, short_name="slow")
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
pf = vbt.Portfolio.from_signals(close, entries, exits, fees=0.003)
# Find best params by Sharpe
sharpe = pf.sharpe_ratio()
best_idx = sharpe.idxmax()
print(f"Best params: {best_idx}, Sharpe: {sharpe[best_idx]:.3f}")Walk-Forward Validation
Always validate optimized parameters on out-of-sample data:
# Split: 70% train, 30% test
split_idx = int(len(close) * 0.7)
train_close = close.iloc[:split_idx]
test_close = close.iloc[split_idx:]
# Optimize on training data
# ... (run grid search on train_close)
# Validate best params on test data
# ... (run single backtest on test_close with best params)See references/optimization_guide.md for detailed walk-forward methodology and overfitting prevention.
Crypto-Specific Considerations
24/7 Markets
Crypto markets never close. Use hourly or minute-based frequencies, not business-day frequencies:
# Correct for crypto
pf = vbt.Portfolio.from_signals(close, entries, exits, freq="1h")
# Wrong — business days assume market closures
# pf = vbt.Portfolio.from_signals(close, entries, exits, freq="1B")Realistic Fees
DEX swaps on Solana typically cost 0.25–1% including AMM fees. CEX spot fees are 0.05–0.1%.
# Solana DEX (conservative)
pf = vbt.Portfolio.from_signals(close, entries, exits, fees=0.005)
# CEX spot
pf = vbt.Portfolio.from_signals(close, entries, exits, fees=0.001)Slippage
Low-liquidity tokens can have 1–5% slippage. Always model this:
# High-liquidity (SOL, ETH): 0.1–0.5%
pf = vbt.Portfolio.from_signals(close, entries, exits, slippage=0.003)
# Low-liquidity memecoins: 1–3%
pf = vbt.Portfolio.from_signals(close, entries, exits, slippage=0.02)Short History
Many tokens have less than 1 year of data. Be cautious about annualizing metrics from short samples.
Common Strategy Patterns
EMA Crossover
fast = vbt.MA.run(close, 12, short_name="fast")
slow = vbt.MA.run(close, 26, short_name="slow")
entries = fast.ma_crossed_above(slow)
exits = fast.ma_crossed_below(slow)RSI Mean Reversion
rsi = vbt.RSI.run(close, 14)
entries = rsi.rsi_crossed_below(30)
exits = rsi.rsi_crossed_above(70)Bollinger Band Breakout
bb = vbt.BBANDS.run(close, window=20, alpha=2)
entries = close > bb.upper
exits = close < bb.lowerStop-Loss and Take-Profit
pf = vbt.Portfolio.from_signals(
close, entries, exits,
sl_stop=0.05, # 5% stop-loss
tp_stop=0.10, # 10% take-profit
)Related Skills
- pandas-ta — Technical indicator computation (feeds vectorbt signals)
- birdeye-api — Fetch Solana token OHLCV data for backtesting
- trading-visualization — Advanced chart generation for backtest results
- portfolio-analytics — Deeper portfolio-level risk/return analysis
- position-sizing — Optimal position sizing methodology
- risk-management — Portfolio-level risk guardrails
- regime-detection — Market regime awareness for adaptive strategies
Files
References
references/api_guide.md— Complete vectorbt API reference for Portfolio, indicators, plotting, and data loadingreferences/optimization_guide.md— Grid search, walk-forward validation, overfitting prevention, and optimization best practices
Scripts
scripts/backtest_example.py— Three-strategy backtest comparison using synthetic data (EMA crossover, RSI mean reversion, Bollinger breakout)scripts/parameter_sweep.py— EMA crossover parameter grid search with walk-forward validation
vectorbt API Guide
Portfolio.from_signals()
The primary backtesting entry point. Takes price data and boolean entry/exit signals, simulates trades, returns a Portfolio object.
pf = vbt.Portfolio.from_signals(
close, # pd.Series or DataFrame of close prices
entries, # boolean Series/DataFrame — True = enter long
exits, # boolean Series/DataFrame — True = exit long
short_entries=None, # boolean — True = enter short
short_exits=None, # boolean — True = exit short
init_cash=100.0, # starting capital (float or array)
fees=0.0, # fee fraction per trade (0.001 = 0.1%)
slippage=0.0, # slippage fraction (0.005 = 0.5%)
size=np.inf, # position size (inf = use all available cash)
size_type="amount", # "amount", "value", "percent"
direction="longonly", # "longonly", "shortonly", "both"
accumulate=False, # allow adding to existing position
sl_stop=None, # stop-loss fraction (0.05 = 5%)
tp_stop=None, # take-profit fraction (0.10 = 10%)
freq=None, # data frequency ("1h", "1d", etc.)
upon_opposite_entry="reversereduce", # action on conflicting signals
)Size Type Options
| Value | Meaning | Example |
|---|---|---|
"amount" | Number of units (shares/tokens) | size=100 → buy 100 tokens |
"value" | Dollar/SOL value | size=1000 → buy $1000 worth |
"percent" | Fraction of available cash | size=0.95 → use 95% of cash |
Direction Options
| Value | Meaning |
|---|---|
"longonly" | Only long positions (default) |
"shortonly" | Only short positions |
"both" | Allow both long and short |
Portfolio.from_orders()
For complex order logic beyond simple signals:
pf = vbt.Portfolio.from_orders(
close=close,
size=order_sizes, # Series of order sizes (+buy, -sell)
size_type="amount",
init_cash=10_000,
fees=0.003,
freq="1h",
)Use from_orders() when you need variable position sizes, partial exits, or order-level logic that signals can't express.
Portfolio Object — Key Methods
Summary Statistics
pf.stats() # Full stats dictionary
pf.stats(agg_func=None) # Stats for each column (multi-param)Return Metrics
pf.total_return() # Cumulative return (float)
pf.annualized_return() # CAGR
pf.returns() # pd.Series of period returns
pf.cumulative_returns() # pd.Series of cumulative returns
pf.daily_returns() # Daily return seriesRisk Metrics
pf.max_drawdown() # Maximum peak-to-trough decline
pf.max_drawdown_duration() # Longest drawdown in time
pf.annualized_volatility() # Annualized std dev of returns
pf.value_at_risk() # Value at Risk
pf.conditional_value_at_risk() # CVaR / Expected ShortfallRisk-Adjusted Metrics
pf.sharpe_ratio() # Sharpe ratio (default rf=0)
pf.sortino_ratio() # Sortino ratio
pf.calmar_ratio() # Calmar ratio
pf.omega_ratio() # Omega ratio
pf.information_ratio() # Information ratio (vs benchmark)Trade Analysis
pf.trades.count() # Number of completed trades
pf.trades.win_rate() # Fraction of winners
pf.trades.profit_factor() # Gross profit / gross loss
pf.trades.expectancy() # Average P&L per trade
pf.trades.avg_winning_trade() # Mean winner P&L
pf.trades.avg_losing_trade() # Mean loser P&L
pf.trades.records_readable # DataFrame of all tradesPortfolio Value
pf.value() # Portfolio value over time
pf.cash() # Cash balance over time
pf.asset_value() # Asset value over timePlotting
# Equity curve
pf.plot().show()
# Drawdowns
pf.drawdowns.plot().show()
# Trade markers on price chart
pf.trades.plot().show()
# Cumulative returns
pf.cumulative_returns().vbt.plot().show()Heatmaps for Parameter Sweeps
# 2D heatmap of metric across parameter grid
sharpe = pf.sharpe_ratio()
sharpe_2d = sharpe.unstack() # reshape to 2D
sharpe_2d.vbt.heatmap(
x_level="fast_window",
y_level="slow_window",
title="Sharpe Ratio by EMA Parameters"
).show()Built-in Indicators
Moving Average — vbt.MA
ma = vbt.MA.run(close, window=[10, 20, 50], short_name="ma")
ma.ma # MA values (multi-column if array window)
ma.ma_above(close) # Boolean: MA > close
ma.ma_below(close) # Boolean: MA < close
ma.ma_crossed_above(close) # Boolean: MA crossed above close
ma.ma_crossed_below(close) # Boolean: MA crossed below closeRSI — vbt.RSI
rsi = vbt.RSI.run(close, window=14)
rsi.rsi # RSI values
rsi.rsi_above(70) # Boolean: RSI > 70
rsi.rsi_below(30) # Boolean: RSI < 30
rsi.rsi_crossed_above(70) # Boolean: RSI crossed above 70
rsi.rsi_crossed_below(30) # Boolean: RSI crossed below 30Bollinger Bands — vbt.BBANDS
bb = vbt.BBANDS.run(close, window=20, alpha=2)
bb.upper # Upper band
bb.middle # Middle band (SMA)
bb.lower # Lower band
bb.bandwidth # Band width
bb.percent_b # %B indicatorATR — vbt.ATR
atr = vbt.ATR.run(high, low, close, window=14)
atr.atr # ATR valuesParameter Arrays and Grid Search
When you pass an array for an indicator parameter, vectorbt computes all values simultaneously and returns a multi-column DataFrame:
# Single window → single-column output
ma_20 = vbt.MA.run(close, window=20)
# Array of windows → multi-column output
ma_multi = vbt.MA.run(close, window=[10, 20, 50])
# Result has columns: (10,), (20,), (50,)For 2D grid search, use two indicators with different parameters:
fast_ma = vbt.MA.run(close, [5, 10, 15], short_name="fast")
slow_ma = vbt.MA.run(close, [20, 30, 40], short_name="slow")
# Broadcasting creates 3x3=9 combinations
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
# entries/exits have MultiIndex columns: (fast, slow) pairsData Loading
Yahoo Finance (Traditional Assets)
data = vbt.YFData.download(
"BTC-USD",
start="2023-01-01",
end="2025-01-01",
interval="1d",
)
close = data.get("Close")Custom Data (Crypto / Solana)
import pandas as pd
# Load from CSV or API response
df = pd.DataFrame({
"timestamp": timestamps,
"open": opens,
"high": highs,
"low": lows,
"close": closes,
"volume": volumes,
})
df["timestamp"] = pd.to_datetime(df["timestamp"])
df = df.set_index("timestamp").sort_index()
# Use close prices for backtesting
close = df["close"]For Solana token data, see the birdeye-api skill for fetching OHLCV data via the Birdeye API.
Tips
- Multi-column operations: When entries/exits have multiple columns (from parameter arrays),
Portfolio.from_signals()automatically runs all combinations. - Memory: Large parameter sweeps with long time series can use significant RAM. Start with coarse grids and narrow down.
- Frequency: Always set
freqcorrectly — it affects annualization of Sharpe, volatility, and other time-scaled metrics. - NaN handling: vectorbt treats NaN signals as "no action". Indicator warm-up periods produce NaN automatically.
vectorbt Optimization Guide
Grid Search Methodology
Grid search tests every combination of parameter values. vectorbt makes this fast by vectorizing all combinations in a single pass.
Step-by-Step Grid Search
import numpy as np
import vectorbt as vbt
# 1. Define parameter ranges
fast_windows = np.arange(5, 25, 2) # [5, 7, 9, ..., 23] → 10 values
slow_windows = np.arange(20, 60, 5) # [20, 25, 30, ..., 55] → 8 values
# Total combinations: 10 × 8 = 80
# 2. Run indicators with array parameters
fast_ma = vbt.MA.run(close, fast_windows, short_name="fast")
slow_ma = vbt.MA.run(close, slow_windows, short_name="slow")
# 3. Generate signals (broadcasted across all combos)
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
# 4. Run portfolio for all combos simultaneously
pf = vbt.Portfolio.from_signals(
close, entries, exits,
init_cash=10_000,
fees=0.003,
slippage=0.005,
freq="1h",
)
# 5. Extract metrics
sharpe = pf.sharpe_ratio()
total_return = pf.total_return()
max_dd = pf.max_drawdown()
trade_count = pf.trades.count()
# 6. Find best parameters
best_sharpe_idx = sharpe.idxmax()
print(f"Best Sharpe: {sharpe[best_sharpe_idx]:.3f} at {best_sharpe_idx}")Filtering Results
# Only consider combos with enough trades
valid = trade_count >= 20
filtered_sharpe = sharpe[valid]
best = filtered_sharpe.idxmax()
# Only consider combos where fast < slow (sensible constraint)
# This is handled automatically if your parameter ranges don't overlapHeatmap Visualization
# Reshape to 2D for heatmap
sharpe_2d = sharpe.unstack(level="slow_window")
sharpe_2d.vbt.heatmap(
title="Sharpe Ratio: Fast vs Slow EMA",
).show()Walk-Forward Optimization
In-sample optimization always overfits. Walk-forward validation is the minimum standard for credible results.
Concept
1. Split data into sequential windows 2. Optimize parameters on each training window 3. Test optimized parameters on the next (unseen) window 4. Aggregate out-of-sample results
Implementation
import pandas as pd
import numpy as np
import vectorbt as vbt
def walk_forward_backtest(
close: pd.Series,
fast_windows: list[int],
slow_windows: list[int],
train_frac: float = 0.7,
n_splits: int = 3,
fees: float = 0.003,
slippage: float = 0.005,
) -> dict:
"""Run walk-forward optimization on EMA crossover strategy.
Args:
close: Price series.
fast_windows: Fast EMA periods to test.
slow_windows: Slow EMA periods to test.
train_frac: Fraction of each window used for training.
n_splits: Number of walk-forward windows.
fees: Trading fee fraction.
slippage: Slippage fraction.
Returns:
Dictionary with in-sample and out-of-sample results.
"""
total_len = len(close)
window_size = total_len // n_splits
results = []
for i in range(n_splits):
start = i * window_size
end = min(start + window_size, total_len)
window = close.iloc[start:end]
split = int(len(window) * train_frac)
train = window.iloc[:split]
test = window.iloc[split:]
# Optimize on training data
fast_ma = vbt.MA.run(train, fast_windows, short_name="fast")
slow_ma = vbt.MA.run(train, slow_windows, short_name="slow")
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
pf_train = vbt.Portfolio.from_signals(
train, entries, exits,
fees=fees, slippage=slippage, freq="1h",
)
best_params = pf_train.sharpe_ratio().idxmax()
# Validate on test data with best params
best_fast, best_slow = best_params
fast_test = vbt.MA.run(test, best_fast, short_name="fast")
slow_test = vbt.MA.run(test, best_slow, short_name="slow")
ent_test = fast_test.ma_crossed_above(slow_test)
ext_test = fast_test.ma_crossed_below(slow_test)
pf_test = vbt.Portfolio.from_signals(
test, ent_test, ext_test,
fees=fees, slippage=slippage, freq="1h",
)
results.append({
"window": i,
"best_fast": best_fast,
"best_slow": best_slow,
"is_sharpe": pf_train.sharpe_ratio()[best_params],
"oos_sharpe": pf_test.sharpe_ratio(),
"is_return": pf_train.total_return()[best_params],
"oos_return": pf_test.total_return(),
})
return resultsInterpreting Walk-Forward Results
| Metric | Good Sign | Warning Sign |
|---|---|---|
| OOS Sharpe / IS Sharpe | > 0.5 | < 0.3 |
| OOS returns | Positive across most windows | Negative in most windows |
| Parameter stability | Same params chosen repeatedly | Different params every window |
| OOS drawdown | Smaller than IS drawdown | Much larger than IS |
Overfitting Prevention
1. Minimum Trade Count
Never trust results with fewer than 30 trades per parameter combination. Ideally 100+.
# Filter out low-trade-count results
valid_mask = pf.trades.count() >= 30
sharpe_filtered = pf.sharpe_ratio()[valid_mask]2. Parameter Stability
If optimal parameters change dramatically across walk-forward windows, the strategy is likely overfit.
# Check if best params are consistent
for r in wf_results:
print(f"Window {r['window']}: fast={r['best_fast']}, slow={r['best_slow']}")
# Consistent: fast=12 in all windows
# Overfit: fast=5, 21, 9, 17 across windows3. Simplicity Preference
Fewer parameters = less overfitting. Prefer 2-parameter strategies over 5-parameter ones.
| Parameters | Risk Level | Minimum Data |
|---|---|---|
| 1-2 | Low | 6 months hourly |
| 3-4 | Medium | 1+ year hourly |
| 5+ | High | 2+ years hourly |
4. Out-of-Sample Decay Budget
Expect 30–60% Sharpe decay from in-sample to out-of-sample. If your IS Sharpe is 1.5, expect OOS Sharpe of 0.6–1.0.
5. Deflated Sharpe Ratio
When testing many parameter combos, the best Sharpe is inflated by multiple testing. Apply a haircut:
import numpy as np
def deflated_sharpe(sharpe_best: float, n_trials: int, n_obs: int) -> float:
"""Estimate probability that best Sharpe is genuine.
Args:
sharpe_best: Best observed Sharpe ratio.
n_trials: Number of parameter combinations tested.
n_obs: Number of return observations.
Returns:
Approximate deflated Sharpe (lower is more skeptical).
"""
expected_max = np.sqrt(2 * np.log(n_trials))
return sharpe_best - expected_max / np.sqrt(n_obs)Common Optimization Mistakes
1. Optimizing on Full Dataset
Using the entire dataset for optimization means there's no unseen data to validate on. Always hold out at least 30% for testing.
2. Cherry-Picking Time Periods
Testing only on bull markets (or bear markets) gives misleading results. Use the full available history.
3. Ignoring Transaction Costs
Strategies that look great with zero fees often break even or lose money with realistic fees. Always include fees and slippage.
4. Annualizing Short Samples
A 50% return over 2 weeks annualizes to ~130,000%. This is meaningless. Require at least 6 months of data before annualizing.
5. Ignoring Drawdown
A strategy with 200% return and 80% max drawdown is not tradeable. Always check max drawdown alongside returns.
6. Data Snooping
If you tried 50 strategy ideas before finding one that works, you've effectively run 50 optimizations. Apply the deflated Sharpe correction.
Combinatorial Purged Cross-Validation (CPCV)
For advanced users, CPCV provides more robust validation than simple walk-forward:
1. Divide data into N groups 2. Select all combinations of N-k groups for training, k groups for testing 3. Purge overlapping data between train and test (remove a buffer) 4. Run optimization on each training set, validate on each test set 5. Aggregate all out-of-sample results
This produces more test paths and is harder to overfit than single walk-forward splits. Implementation requires significant code — see the regime-detection skill for related statistical validation techniques.
Recommended Workflow
1. Explore: Run a coarse grid search (5-10 values per parameter) on 80% of data 2. Narrow: Identify the promising region, run a fine grid search 3. Validate: Walk-forward test with 3-5 splits 4. Stress test: Check OOS Sharpe decay, parameter stability, trade count 5. Deploy cautiously: Start with small size, monitor live performance vs backtest 6. Re-optimize: Re-run optimization monthly or quarterly with new data
#!/usr/bin/env python3
"""Three-strategy backtest comparison using synthetic OHLCV data.
Implements and compares three classic trading strategies:
1. EMA Crossover (fast=12, slow=26)
2. RSI Mean Reversion (buy RSI<30, sell RSI>70)
3. Bollinger Band Breakout (buy above upper, sell below lower)
All backtests use realistic fees (0.3%) and slippage (0.5%) on synthetic
price data with embedded trends and mean-reverting regimes.
Usage:
python scripts/backtest_example.py
Dependencies:
uv pip install vectorbt pandas numpy pandas-ta
"""
import sys
from typing import Optional
import numpy as np
import pandas as pd
try:
import vectorbt as vbt
except ImportError:
print("vectorbt is required. Install with: uv pip install vectorbt")
sys.exit(1)
try:
import pandas_ta as ta # noqa: F401
except ImportError:
print("pandas-ta is required. Install with: uv pip install pandas-ta")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
NUM_BARS: int = 200
INIT_CASH: float = 10_000.0
FEES: float = 0.003 # 0.3% per trade
SLIPPAGE: float = 0.005 # 0.5% slippage
FREQ: str = "1h"
SEED: int = 42
# ── Synthetic Data Generation ──────────────────────────────────────
def generate_synthetic_ohlcv(
num_bars: int = 200,
start_price: float = 100.0,
seed: int = 42,
) -> pd.DataFrame:
"""Generate realistic synthetic OHLCV data with trends and ranges.
Creates price data that includes:
- An uptrend phase (first 30% of bars)
- A ranging/consolidation phase (middle 40%)
- A downtrend phase (last 30%)
Args:
num_bars: Number of OHLCV bars to generate.
start_price: Starting close price.
seed: Random seed for reproducibility.
Returns:
DataFrame with columns: open, high, low, close, volume.
Index is hourly DatetimeIndex.
"""
rng = np.random.default_rng(seed)
# Build returns with regime-dependent drift
returns = np.zeros(num_bars)
phase1 = int(num_bars * 0.3)
phase2 = int(num_bars * 0.7)
# Uptrend: positive drift
returns[:phase1] = rng.normal(0.002, 0.015, phase1)
# Range: near-zero drift, lower vol
returns[phase1:phase2] = rng.normal(0.0, 0.010, phase2 - phase1)
# Downtrend: negative drift
returns[phase2:] = rng.normal(-0.0015, 0.018, num_bars - phase2)
# Build close prices from cumulative returns
close = start_price * np.exp(np.cumsum(returns))
# Derive OHLV from close
spread = rng.uniform(0.002, 0.012, num_bars)
high = close * (1 + spread)
low = close * (1 - spread)
open_prices = close * (1 + rng.normal(0, 0.003, num_bars))
# Ensure open is within high/low
open_prices = np.clip(open_prices, low, high)
volume = rng.uniform(50_000, 500_000, num_bars) * (1 + np.abs(returns) * 20)
timestamps = pd.date_range(
start="2025-01-01", periods=num_bars, freq="1h"
)
df = pd.DataFrame(
{
"open": open_prices,
"high": high,
"low": low,
"close": close,
"volume": volume,
},
index=timestamps,
)
return df
# ── Strategy Implementations ───────────────────────────────────────
def ema_crossover_signals(
close: pd.Series,
fast_period: int = 12,
slow_period: int = 26,
) -> tuple[pd.Series, pd.Series]:
"""Generate EMA crossover entry/exit signals.
Enters when fast EMA crosses above slow EMA.
Exits when fast EMA crosses below slow EMA.
Args:
close: Close price series.
fast_period: Fast EMA window.
slow_period: Slow EMA window.
Returns:
Tuple of (entries, exits) as boolean Series.
"""
fast_ema = close.ewm(span=fast_period, adjust=False).mean()
slow_ema = close.ewm(span=slow_period, adjust=False).mean()
# Cross-above: fast was below, now above
fast_above = fast_ema > slow_ema
entries = fast_above & (~fast_above.shift(1, fill_value=False))
# Cross-below: fast was above, now below
fast_below = fast_ema < slow_ema
exits = fast_below & (~fast_below.shift(1, fill_value=False))
return entries, exits
def rsi_mean_reversion_signals(
close: pd.Series,
rsi_period: int = 14,
oversold: float = 30.0,
overbought: float = 70.0,
) -> tuple[pd.Series, pd.Series]:
"""Generate RSI mean reversion entry/exit signals.
Enters when RSI crosses below oversold threshold.
Exits when RSI crosses above overbought threshold.
Args:
close: Close price series.
rsi_period: RSI calculation period.
oversold: RSI level to trigger entry.
overbought: RSI level to trigger exit.
Returns:
Tuple of (entries, exits) as boolean Series.
"""
df = pd.DataFrame({"close": close}, index=close.index)
df.ta.rsi(length=rsi_period, append=True)
rsi_col = f"RSI_{rsi_period}"
rsi = df[rsi_col]
# Enter when RSI crosses below oversold
below = rsi < oversold
entries = below & (~below.shift(1, fill_value=False))
# Exit when RSI crosses above overbought
above = rsi > overbought
exits = above & (~above.shift(1, fill_value=False))
return entries, exits
def bollinger_breakout_signals(
close: pd.Series,
bb_period: int = 20,
bb_std: float = 2.0,
) -> tuple[pd.Series, pd.Series]:
"""Generate Bollinger Band breakout entry/exit signals.
Enters when close crosses above the upper band.
Exits when close crosses below the lower band.
Args:
close: Close price series.
bb_period: Bollinger Band period.
bb_std: Number of standard deviations.
Returns:
Tuple of (entries, exits) as boolean Series.
"""
df = pd.DataFrame({"close": close}, index=close.index)
df.ta.bbands(length=bb_period, std=bb_std, append=True)
upper_col = f"BBU_{bb_period}_{bb_std}"
lower_col = f"BBL_{bb_period}_{bb_std}"
upper = df[upper_col]
lower = df[lower_col]
# Enter when price crosses above upper band
above_upper = close > upper
entries = above_upper & (~above_upper.shift(1, fill_value=False))
# Exit when price crosses below lower band
below_lower = close < lower
exits = below_lower & (~below_lower.shift(1, fill_value=False))
return entries, exits
# ── Backtest Runner ─────────────────────────────────────────────────
def run_backtest(
close: pd.Series,
entries: pd.Series,
exits: pd.Series,
strategy_name: str,
init_cash: float = INIT_CASH,
fees: float = FEES,
slippage: float = SLIPPAGE,
freq: str = FREQ,
) -> dict:
"""Run a single backtest and return key metrics.
Args:
close: Close price series.
entries: Boolean entry signals.
exits: Boolean exit signals.
strategy_name: Name for display purposes.
init_cash: Starting capital.
fees: Fee fraction per trade.
slippage: Slippage fraction.
freq: Data frequency string.
Returns:
Dictionary with strategy name and performance metrics.
"""
pf = vbt.Portfolio.from_signals(
close=close,
entries=entries,
exits=exits,
init_cash=init_cash,
fees=fees,
slippage=slippage,
size=0.95,
size_type="percent",
freq=freq,
)
trade_count = pf.trades.count()
# Handle edge case of zero trades
if trade_count == 0:
return {
"Strategy": strategy_name,
"Total Return": 0.0,
"Sharpe Ratio": 0.0,
"Sortino Ratio": 0.0,
"Max Drawdown": 0.0,
"Win Rate": 0.0,
"Profit Factor": 0.0,
"Trade Count": 0,
"Avg Trade PnL": 0.0,
}
win_rate = pf.trades.win_rate()
profit_factor = pf.trades.profit_factor()
return {
"Strategy": strategy_name,
"Total Return": float(pf.total_return()),
"Sharpe Ratio": float(pf.sharpe_ratio()),
"Sortino Ratio": float(pf.sortino_ratio()),
"Max Drawdown": float(pf.max_drawdown()),
"Win Rate": float(win_rate) if not np.isnan(win_rate) else 0.0,
"Profit Factor": float(profit_factor) if not np.isnan(profit_factor) else 0.0,
"Trade Count": int(trade_count),
"Avg Trade PnL": float(pf.trades.expectancy()),
}
# ── Display ─────────────────────────────────────────────────────────
def print_comparison_table(results: list[dict]) -> None:
"""Print a formatted comparison table of backtest results.
Args:
results: List of result dictionaries from run_backtest().
"""
print("\n" + "=" * 80)
print("STRATEGY COMPARISON")
print("=" * 80)
print(
f"{'Strategy':<25} {'Return':>10} {'Sharpe':>8} {'Sortino':>8} "
f"{'MaxDD':>8} {'WinRate':>8} {'PF':>8} {'Trades':>7}"
)
print("-" * 80)
for r in results:
print(
f"{r['Strategy']:<25} "
f"{r['Total Return']:>9.2%} "
f"{r['Sharpe Ratio']:>8.3f} "
f"{r['Sortino Ratio']:>8.3f} "
f"{r['Max Drawdown']:>7.2%} "
f"{r['Win Rate']:>7.2%} "
f"{r['Profit Factor']:>8.2f} "
f"{r['Trade Count']:>7d}"
)
print("-" * 80)
# Find best strategy by Sharpe ratio
best = max(results, key=lambda r: r["Sharpe Ratio"])
print(f"\nBest strategy by Sharpe Ratio: {best['Strategy']}")
print(f" Sharpe: {best['Sharpe Ratio']:.3f}")
print(f" Return: {best['Total Return']:.2%}")
print(f" Max Drawdown: {best['Max Drawdown']:.2%}")
print(f" Win Rate: {best['Win Rate']:.2%}")
print(f" Trades: {best['Trade Count']}")
def print_data_summary(df: pd.DataFrame) -> None:
"""Print summary statistics of the synthetic data.
Args:
df: OHLCV DataFrame.
"""
print("SYNTHETIC DATA SUMMARY")
print(f" Bars: {len(df)}")
print(f" Period: {df.index[0]} to {df.index[-1]}")
print(f" Start price: {df['close'].iloc[0]:.2f}")
print(f" End price: {df['close'].iloc[-1]:.2f}")
print(f" High: {df['high'].max():.2f}")
print(f" Low: {df['low'].min():.2f}")
buy_hold = (df["close"].iloc[-1] / df["close"].iloc[0]) - 1
print(f" Buy & hold return: {buy_hold:.2%}")
print()
print("BACKTEST SETTINGS")
print(f" Initial cash: ${INIT_CASH:,.0f}")
print(f" Fees: {FEES:.1%} per trade")
print(f" Slippage: {SLIPPAGE:.1%}")
print(f" Position size: 95% of available cash")
print(f" Frequency: {FREQ}")
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run three-strategy backtest comparison."""
# Generate synthetic data
print("Generating synthetic OHLCV data...\n")
df = generate_synthetic_ohlcv(num_bars=NUM_BARS, seed=SEED)
close = df["close"]
print_data_summary(df)
# Strategy 1: EMA Crossover
print("\nRunning EMA Crossover (12/26)...")
ema_entries, ema_exits = ema_crossover_signals(close, fast_period=12, slow_period=26)
ema_result = run_backtest(close, ema_entries, ema_exits, "EMA Crossover (12/26)")
# Strategy 2: RSI Mean Reversion
print("Running RSI Mean Reversion (14, 30/70)...")
rsi_entries, rsi_exits = rsi_mean_reversion_signals(close, rsi_period=14)
rsi_result = run_backtest(close, rsi_entries, rsi_exits, "RSI MeanRev (14, 30/70)")
# Strategy 3: Bollinger Breakout
print("Running Bollinger Breakout (20, 2.0)...")
bb_entries, bb_exits = bollinger_breakout_signals(close, bb_period=20, bb_std=2.0)
bb_result = run_backtest(close, bb_entries, bb_exits, "BB Breakout (20, 2.0)")
# Compare results
results = [ema_result, rsi_result, bb_result]
print_comparison_table(results)
print("\nNote: Results are based on synthetic data for demonstration only.")
print("Past performance does not indicate future results.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""EMA crossover parameter sweep with walk-forward validation.
Demonstrates:
1. Grid search across fast/slow EMA period combinations
2. Ranking parameter combos by Sharpe, total return, and profit factor
3. Walk-forward validation (train on first 70%, test on last 30%)
4. In-sample vs out-of-sample performance comparison
Uses synthetic OHLCV data — no API keys required.
Usage:
python scripts/parameter_sweep.py
Dependencies:
uv pip install vectorbt pandas numpy
"""
import sys
from typing import Optional
import numpy as np
import pandas as pd
try:
import vectorbt as vbt
except ImportError:
print("vectorbt is required. Install with: uv pip install vectorbt")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
NUM_BARS: int = 500
INIT_CASH: float = 10_000.0
FEES: float = 0.003
SLIPPAGE: float = 0.005
FREQ: str = "1h"
SEED: int = 99
FAST_PERIODS: list[int] = [5, 8, 12, 15, 20]
SLOW_PERIODS: list[int] = [20, 26, 30, 40, 50]
TRAIN_FRACTION: float = 0.7
# ── Synthetic Data Generation ──────────────────────────────────────
def generate_synthetic_ohlcv(
num_bars: int = 500,
start_price: float = 50.0,
seed: int = 99,
) -> pd.DataFrame:
"""Generate realistic synthetic OHLCV data with multiple market regimes.
Creates price data with embedded trends, ranges, and reversals to provide
a challenging but realistic test environment for strategy optimization.
Args:
num_bars: Number of OHLCV bars to generate.
start_price: Starting close price.
seed: Random seed for reproducibility.
Returns:
DataFrame with columns: open, high, low, close, volume.
Index is hourly DatetimeIndex.
"""
rng = np.random.default_rng(seed)
returns = np.zeros(num_bars)
# Multiple regime phases
p1 = int(num_bars * 0.15)
p2 = int(num_bars * 0.35)
p3 = int(num_bars * 0.50)
p4 = int(num_bars * 0.70)
p5 = int(num_bars * 0.85)
# Uptrend
returns[:p1] = rng.normal(0.002, 0.012, p1)
# Range
returns[p1:p2] = rng.normal(0.0, 0.008, p2 - p1)
# Strong uptrend
returns[p2:p3] = rng.normal(0.003, 0.015, p3 - p2)
# Downtrend
returns[p3:p4] = rng.normal(-0.002, 0.014, p4 - p3)
# Range
returns[p4:p5] = rng.normal(0.0, 0.009, p5 - p4)
# Mild uptrend
returns[p5:] = rng.normal(0.001, 0.011, num_bars - p5)
close = start_price * np.exp(np.cumsum(returns))
spread = rng.uniform(0.002, 0.010, num_bars)
high = close * (1 + spread)
low = close * (1 - spread)
open_prices = np.clip(
close * (1 + rng.normal(0, 0.003, num_bars)), low, high
)
volume = rng.uniform(100_000, 800_000, num_bars)
timestamps = pd.date_range(start="2025-01-01", periods=num_bars, freq="1h")
return pd.DataFrame(
{
"open": open_prices,
"high": high,
"low": low,
"close": close,
"volume": volume,
},
index=timestamps,
)
# ── Grid Search ─────────────────────────────────────────────────────
def run_grid_search(
close: pd.Series,
fast_periods: list[int],
slow_periods: list[int],
init_cash: float = INIT_CASH,
fees: float = FEES,
slippage: float = SLIPPAGE,
freq: str = FREQ,
) -> vbt.Portfolio:
"""Run EMA crossover grid search across all fast/slow period combinations.
Args:
close: Close price series.
fast_periods: List of fast EMA periods to test.
slow_periods: List of slow EMA periods to test.
init_cash: Starting capital.
fees: Fee fraction per trade.
slippage: Slippage fraction.
freq: Data frequency string.
Returns:
Portfolio object containing all parameter combinations.
"""
fast_ma = vbt.MA.run(close, fast_periods, short_name="fast")
slow_ma = vbt.MA.run(close, slow_periods, short_name="slow")
entries = fast_ma.ma_crossed_above(slow_ma)
exits = fast_ma.ma_crossed_below(slow_ma)
pf = vbt.Portfolio.from_signals(
close=close,
entries=entries,
exits=exits,
init_cash=init_cash,
fees=fees,
slippage=slippage,
size=0.95,
size_type="percent",
freq=freq,
)
return pf
def extract_metrics(pf: vbt.Portfolio) -> pd.DataFrame:
"""Extract key metrics from a multi-parameter portfolio.
Args:
pf: Portfolio object from grid search.
Returns:
DataFrame with one row per parameter combination and columns for
each metric.
"""
sharpe = pf.sharpe_ratio()
total_ret = pf.total_return()
max_dd = pf.max_drawdown()
trade_count = pf.trades.count()
win_rate = pf.trades.win_rate()
profit_factor = pf.trades.profit_factor()
df = pd.DataFrame({
"sharpe": sharpe,
"total_return": total_ret,
"max_drawdown": max_dd,
"trade_count": trade_count,
"win_rate": win_rate,
"profit_factor": profit_factor,
})
return df
# ── Walk-Forward Validation ─────────────────────────────────────────
def walk_forward_validate(
close: pd.Series,
fast_periods: list[int],
slow_periods: list[int],
train_fraction: float = TRAIN_FRACTION,
fees: float = FEES,
slippage: float = SLIPPAGE,
freq: str = FREQ,
) -> dict:
"""Run walk-forward validation: optimize on train, validate on test.
Splits data into training (first train_fraction) and testing (remainder).
Finds optimal parameters on training data, then evaluates those parameters
on the unseen test data.
Args:
close: Full close price series.
fast_periods: Fast EMA periods to test.
slow_periods: Slow EMA periods to test.
train_fraction: Fraction of data for training.
fees: Fee fraction.
slippage: Slippage fraction.
freq: Data frequency.
Returns:
Dictionary with training results, test results, and best parameters.
"""
split_idx = int(len(close) * train_fraction)
train_close = close.iloc[:split_idx]
test_close = close.iloc[split_idx:]
# ── Train phase: optimize ──
pf_train = run_grid_search(
train_close, fast_periods, slow_periods,
fees=fees, slippage=slippage, freq=freq,
)
train_metrics = extract_metrics(pf_train)
# Filter: require at least 5 trades
valid = train_metrics["trade_count"] >= 5
if not valid.any():
print("WARNING: No parameter combo produced >= 5 trades on training data.")
valid = train_metrics["trade_count"] >= 1
valid_sharpe = train_metrics.loc[valid, "sharpe"]
best_params = valid_sharpe.idxmax()
# Extract fast and slow from the MultiIndex
if isinstance(best_params, tuple):
best_fast, best_slow = best_params
else:
best_fast, best_slow = best_params, slow_periods[0]
train_sharpe = float(valid_sharpe[best_params])
train_return = float(train_metrics.loc[best_params, "total_return"])
train_trades = int(train_metrics.loc[best_params, "trade_count"])
# ── Test phase: validate ──
fast_ma_test = vbt.MA.run(test_close, int(best_fast), short_name="fast")
slow_ma_test = vbt.MA.run(test_close, int(best_slow), short_name="slow")
entries_test = fast_ma_test.ma_crossed_above(slow_ma_test)
exits_test = fast_ma_test.ma_crossed_below(slow_ma_test)
pf_test = vbt.Portfolio.from_signals(
close=test_close,
entries=entries_test,
exits=exits_test,
init_cash=INIT_CASH,
fees=fees,
slippage=slippage,
size=0.95,
size_type="percent",
freq=freq,
)
test_sharpe = float(pf_test.sharpe_ratio())
test_return = float(pf_test.total_return())
test_trades = int(pf_test.trades.count())
test_max_dd = float(pf_test.max_drawdown())
return {
"best_fast": int(best_fast),
"best_slow": int(best_slow),
"train_sharpe": train_sharpe,
"train_return": train_return,
"train_trades": train_trades,
"test_sharpe": test_sharpe,
"test_return": test_return,
"test_trades": test_trades,
"test_max_dd": test_max_dd,
"train_bars": split_idx,
"test_bars": len(close) - split_idx,
}
# ── Display Functions ───────────────────────────────────────────────
def print_grid_results(
metrics: pd.DataFrame,
top_n: int = 5,
) -> None:
"""Print top parameter combinations by different metrics.
Args:
metrics: DataFrame from extract_metrics().
top_n: Number of top results to show.
"""
# Filter combos with at least 5 trades
valid = metrics[metrics["trade_count"] >= 5].copy()
if valid.empty:
valid = metrics[metrics["trade_count"] >= 1].copy()
print("\n" + "=" * 80)
print("GRID SEARCH RESULTS")
print(f"Total parameter combinations: {len(metrics)}")
print(f"Valid combinations (>= 5 trades): {len(valid)}")
print("=" * 80)
# Best by Sharpe
print(f"\n--- Top {top_n} by Sharpe Ratio ---")
top_sharpe = valid.nlargest(top_n, "sharpe")
_print_metric_table(top_sharpe)
# Best by Total Return
print(f"\n--- Top {top_n} by Total Return ---")
top_return = valid.nlargest(top_n, "total_return")
_print_metric_table(top_return)
# Best by Profit Factor
pf_valid = valid[valid["profit_factor"].notna() & (valid["profit_factor"] < np.inf)]
if not pf_valid.empty:
print(f"\n--- Top {top_n} by Profit Factor ---")
top_pf = pf_valid.nlargest(top_n, "profit_factor")
_print_metric_table(top_pf)
def _print_metric_table(df: pd.DataFrame) -> None:
"""Print a formatted table of metrics.
Args:
df: DataFrame with metric columns.
"""
print(
f" {'Params':<20} {'Sharpe':>8} {'Return':>10} {'MaxDD':>8} "
f"{'WinRate':>8} {'PF':>8} {'Trades':>7}"
)
print(" " + "-" * 70)
for idx, row in df.iterrows():
params_str = str(idx)
sharpe = row["sharpe"]
ret = row["total_return"]
mdd = row["max_drawdown"]
wr = row["win_rate"]
pf_val = row["profit_factor"]
tc = row["trade_count"]
print(
f" {params_str:<20} "
f"{sharpe:>8.3f} "
f"{ret:>9.2%} "
f"{mdd:>7.2%} "
f"{wr if not np.isnan(wr) else 0:>7.2%} "
f"{pf_val if not np.isnan(pf_val) else 0:>8.2f} "
f"{int(tc):>7d}"
)
def print_walk_forward_results(wf: dict) -> None:
"""Print walk-forward validation results.
Args:
wf: Dictionary from walk_forward_validate().
"""
print("\n" + "=" * 80)
print("WALK-FORWARD VALIDATION")
print("=" * 80)
print(f"\nBest parameters (optimized on training data):")
print(f" Fast EMA: {wf['best_fast']}")
print(f" Slow EMA: {wf['best_slow']}")
print()
print(f"{'Metric':<25} {'In-Sample':>15} {'Out-of-Sample':>15}")
print("-" * 55)
print(f"{'Bars':<25} {wf['train_bars']:>15d} {wf['test_bars']:>15d}")
print(f"{'Sharpe Ratio':<25} {wf['train_sharpe']:>15.3f} {wf['test_sharpe']:>15.3f}")
print(f"{'Total Return':<25} {wf['train_return']:>14.2%} {wf['test_return']:>14.2%}")
print(f"{'Trade Count':<25} {wf['train_trades']:>15d} {wf['test_trades']:>15d}")
if wf["test_max_dd"] > 0:
print(f"{'Max Drawdown (OOS)':<25} {'':>15s} {wf['test_max_dd']:>14.2%}")
# Sharpe decay analysis
if wf["train_sharpe"] > 0:
decay = 1.0 - (wf["test_sharpe"] / wf["train_sharpe"])
print(f"\nSharpe decay: {decay:.1%}")
if decay < 0.3:
assessment = "Low decay — parameters appear robust"
elif decay < 0.6:
assessment = "Moderate decay — use with caution"
else:
assessment = "High decay — likely overfit, consider simpler strategy"
print(f"Assessment: {assessment}")
elif wf["train_sharpe"] <= 0:
print("\nIn-sample Sharpe <= 0: strategy may not be viable for this data.")
print()
print("Note: This analysis uses synthetic data for demonstration only.")
print("Real-world performance depends on data quality, market conditions,")
print("and execution factors not captured in backtesting.")
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Run parameter sweep and walk-forward validation."""
print("Generating synthetic OHLCV data...")
print(f" Bars: {NUM_BARS}")
print(f" Frequency: {FREQ}")
print(f" Fees: {FEES:.1%}")
print(f" Slippage: {SLIPPAGE:.1%}")
df = generate_synthetic_ohlcv(num_bars=NUM_BARS, seed=SEED)
close = df["close"]
print(f" Price range: {close.min():.2f} — {close.max():.2f}")
buy_hold = (close.iloc[-1] / close.iloc[0]) - 1
print(f" Buy & hold return: {buy_hold:.2%}")
# ── Full-sample grid search ──
print(f"\nRunning grid search...")
print(f" Fast periods: {FAST_PERIODS}")
print(f" Slow periods: {SLOW_PERIODS}")
print(f" Total combinations: {len(FAST_PERIODS) * len(SLOW_PERIODS)}")
pf = run_grid_search(close, FAST_PERIODS, SLOW_PERIODS)
metrics = extract_metrics(pf)
print_grid_results(metrics, top_n=5)
# ── Walk-forward validation ──
print(f"\nRunning walk-forward validation...")
print(f" Train/test split: {TRAIN_FRACTION:.0%} / {1 - TRAIN_FRACTION:.0%}")
wf_results = walk_forward_validate(
close, FAST_PERIODS, SLOW_PERIODS,
train_fraction=TRAIN_FRACTION,
)
print_walk_forward_results(wf_results)
if __name__ == "__main__":
main()
Related skills
FAQ
How fast is vectorbt?
It runs vectorized simulations 100-1000x faster than event-driven frameworks like backtrader or zipline, enabling optimization over thousands of combinations in seconds.
How are strategies expressed?
As boolean pandas entry and exit signal arrays passed to vbt.Portfolio.from_signals().