
Backtesting Trading Strategies
- 3.9k installs
- 2.6k repo stars
- Updated July 28, 2026
- jeremylongshore/claude-code-plugins-plus-skills
backtesting-trading-strategies is an agent skill for backtest crypto and traditional trading strategies with sharpe, drawdown metrics, and parameter grid search.
About
The backtesting-trading-strategies skill Backtest crypto and traditional trading strategies against historical. Validate trading strategies against historical data before risking real capital. This skill provides a complete backtesting framework with 8 built-in strategies, comprehensive performance metrics, and parameter optimization. - 8 pre-built trading strategies (SMA, EMA, RSI, MACD, Bollinger, Breakout, Mean Reversion, Momentum) - Full performance metrics (Sharpe, Sortino, Calmar, VaR, max drawdown) - Parameter grid search optimization - Equity curve visualization - Trade-by-trade analysis ``bash set -euo pipefail pip install pandas numpy yfinance matplotlib `` ``bash set -euo pipefail pip install ta-lib scipy scikit-learn `` bash set -euo pipefail pip install ta-lib scipy scikit-learn 1. Fetch historical data (cached to ${CLAUDE_SKILL_DIR}/data/ for reuse): bash python ${CLAUDE_SKILL_DIR}/scripts/fetch_data.py --symbol BTC-USD --period 2y --interval 1d 2. Run a backtest with default or custom parameters: bash python ${CLAUDE_SKILL_DIR}/scripts/backtest.py --strategy sma_crossover --symbol BTC-USD --period 1y python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \ --strategy rsi_re.
- 8 pre-built trading strategies (SMA, EMA, RSI, MACD, Bollinger, Breakout, Mean Reversion, Momentum)
- Full performance metrics (Sharpe, Sortino, Calmar, VaR, max drawdown)
- Parameter grid search optimization
- Equity curve visualization
- Trade-by-trade analysis
Backtesting Trading Strategies by the numbers
- 3,924 all-time installs (skills.sh)
- +63 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #28 of 1,136 Finance & Trading skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
backtesting-trading-strategies capabilities & compatibility
- Capabilities
- 8 pre built trading strategies (sma, ema, rsi, m · full performance metrics (sharpe, sortino, calma · parameter grid search optimization · equity curve visualization · trade by trade analysis
- Use cases
- trading · data analysis
What backtesting-trading-strategies says it does
pip install pandas numpy yfinance matplotlib
pip install ta-lib scipy scikit-learn
1. Fetch historical data (cached to `${CLAUDE_SKILL_DIR}/data/` for reuse):
npx skills add https://github.com/jeremylongshore/claude-code-plugins-plus-skills --skill backtesting-trading-strategiesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.9k |
|---|---|
| repo stars | ★ 2.6k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | jeremylongshore/claude-code-plugins-plus-skills ↗ |
How do I backtest crypto and traditional trading strategies with sharpe, drawdown metrics, and parameter grid search with documented agent guidance?
Backtest crypto and traditional trading strategies with Sharpe, drawdown metrics, and parameter grid search.
Who is it for?
Developers who need finance & trading help during validate work.
Skip if: Skip when the task falls outside Finance & Trading scope described in SKILL.md.
When should I use this skill?
Backtest crypto and traditional trading strategies with Sharpe, drawdown metrics, and parameter grid search.
What you get
Completed finance & trading workflow aligned with SKILL.md steps and validation.
- Backtest simulation results
- Per-bar Signal evaluation log
By the numbers
- 8 pre-built trading strategies (SMA, EMA, RSI, MACD, Bollinger, Breakout, Mean Reversion, Momentum)
- Full performance metrics (Sharpe, Sortino, Calmar, VaR, max drawdown)
- Parameter grid search optimization
Files
Backtesting Trading Strategies
Overview
Validate trading strategies against historical data before risking real capital. This skill provides a complete backtesting framework with 8 built-in strategies, comprehensive performance metrics, and parameter optimization.
Key Features:
- 8 pre-built trading strategies (SMA, EMA, RSI, MACD, Bollinger, Breakout, Mean Reversion, Momentum)
- Full performance metrics (Sharpe, Sortino, Calmar, VaR, max drawdown)
- Parameter grid search optimization
- Equity curve visualization
- Trade-by-trade analysis
Prerequisites
Install required dependencies:
set -euo pipefail
pip install pandas numpy yfinance matplotlibOptional for advanced features:
set -euo pipefail
pip install ta-lib scipy scikit-learnInstructions
1. Fetch historical data (cached to ${CLAUDE_SKILL_DIR}/data/ for reuse):
python ${CLAUDE_SKILL_DIR}/scripts/fetch_data.py --symbol BTC-USD --period 2y --interval 1d2. Run a backtest with default or custom parameters:
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py --strategy sma_crossover --symbol BTC-USD --period 1y
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \
--strategy rsi_reversal \
--symbol ETH-USD \
--period 1y \
--capital 10000 \ # 10000: 10 seconds in ms
--params '{"period": 14, "overbought": 70, "oversold": 30}'3. Analyze results saved to ${CLAUDE_SKILL_DIR}/reports/ -- includes *_summary.txt (performance metrics), *_trades.csv (trade log), *_equity.csv (equity curve data), and *_chart.png (visual equity curve). 4. Optimize parameters via grid search to find the best combination:
python ${CLAUDE_SKILL_DIR}/scripts/optimize.py \
--strategy sma_crossover \
--symbol BTC-USD \
--period 1y \
--param-grid '{"fast_period": [10, 20, 30], "slow_period": [50, 100, 200]}' # HTTP 200 OKOutput
Performance Metrics
| Metric | Description |
|---|---|
| Total Return | Overall percentage gain/loss |
| CAGR | Compound annual growth rate |
| Sharpe Ratio | Risk-adjusted return (target: >1.5) |
| Sortino Ratio | Downside risk-adjusted return |
| Calmar Ratio | Return divided by max drawdown |
Risk Metrics
| Metric | Description |
|---|---|
| Max Drawdown | Largest peak-to-trough decline |
| VaR (95%) | Value at Risk at 95% confidence |
| CVaR (95%) | Expected loss beyond VaR |
| Volatility | Annualized standard deviation |
Trade Statistics
| Metric | Description |
|---|---|
| Total Trades | Number of round-trip trades |
| Win Rate | Percentage of profitable trades |
| Profit Factor | Gross profit divided by gross loss |
| Expectancy | Expected value per trade |
Example Output
================================================================================
BACKTEST RESULTS: SMA CROSSOVER
BTC-USD | [start_date] to [end_date]
================================================================================
PERFORMANCE | RISK
Total Return: +47.32% | Max Drawdown: -18.45%
CAGR: +47.32% | VaR (95%): -2.34%
Sharpe Ratio: 1.87 | Volatility: 42.1%
Sortino Ratio: 2.41 | Ulcer Index: 8.2
--------------------------------------------------------------------------------
TRADE STATISTICS
Total Trades: 24 | Profit Factor: 2.34
Win Rate: 58.3% | Expectancy: $197.17
Avg Win: $892.45 | Max Consec. Losses: 3
================================================================================Supported Strategies
| Strategy | Description | Key Parameters |
|---|---|---|
sma_crossover | Simple moving average crossover | fast_period, slow_period |
ema_crossover | Exponential MA crossover | fast_period, slow_period |
rsi_reversal | RSI overbought/oversold | period, overbought, oversold |
macd | MACD signal line crossover | fast, slow, signal |
bollinger_bands | Mean reversion on bands | period, std_dev |
breakout | Price breakout from range | lookback, threshold |
mean_reversion | Return to moving average | period, z_threshold |
momentum | Rate of change momentum | period, threshold |
Configuration
Create ${CLAUDE_SKILL_DIR}/config/settings.yaml:
data:
provider: yfinance
cache_dir: ./data
backtest:
default_capital: 10000 # 10000: 10 seconds in ms
commission: 0.001 # 0.1% per trade
slippage: 0.0005 # 0.05% slippage
risk:
max_position_size: 0.95
stop_loss: null # Optional fixed stop loss
take_profit: null # Optional fixed take profitError Handling
See ${CLAUDE_SKILL_DIR}/references/errors.md for common issues and solutions.
Examples
See ${CLAUDE_SKILL_DIR}/references/examples.md for detailed usage examples including:
- Multi-asset comparison
- Walk-forward analysis
- Parameter optimization workflows
Files
| File | Purpose |
|---|---|
scripts/backtest.py | Main backtesting engine |
scripts/fetch_data.py | Historical data fetcher |
scripts/strategies.py | Strategy definitions |
scripts/metrics.py | Performance calculations |
scripts/optimize.py | Parameter optimization |
Resources
- yfinance - Yahoo Finance data
- TA-Lib - Technical analysis library
- QuantStats - Portfolio analytics
/backtest-strategy
Run a complete backtest of a trading strategy against historical data.
Usage
/backtest-strategy <strategy_name> <symbol> [options]Core Architecture
The backtester uses three key types:
from dataclasses import dataclass
@dataclass
class Signal:
"""Returned by every strategy on each bar."""
entry: bool = False # True to open a position
exit: bool = False # True to close a position
direction: str = "long" # "long" or "short"
strength: float = 1.0 # 0-1 confidence
class Strategy(ABC):
name: str
lookback: int # minimum bars needed
@abstractmethod
def generate_signals(self, data: pd.DataFrame, params: dict) -> Signal:
...The engine calls run_backtest():
def run_backtest(
strategy_name: str,
data: pd.DataFrame,
initial_capital: float = 10000,
params: dict = None,
commission: float = 0.001,
slippage: float = 0.0005,
risk_settings: dict = None, # stop_loss, take_profit, max_position_size
) -> BacktestResult:Examples
Basic SMA crossover backtest:
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \
--strategy sma_crossover --symbol BTC-USD --period 1yRSI reversal with custom parameters and $50k capital:
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \
--strategy rsi_reversal --symbol ETH-USD --period 6m \
--capital 50000 \
--params '{"period": 14, "overbought": 75, "oversold": 25}'Specific date range with low commission:
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \
--strategy macd --symbol SOL-USD \
--start 2024-01-01 --end 2025-01-01 \
--commission 0.0005 --slippage 0.0002List all available strategies:
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py --listAvailable Strategies
| Strategy | Direction | Key Parameters |
|---|---|---|
sma_crossover | Long only | fast_period, slow_period |
ema_crossover | Long only | fast_period, slow_period |
rsi_reversal | Long + Short | period, overbought, oversold |
macd | Long + Short | fast, slow, signal |
bollinger_bands | Long + Short | period, std_dev |
breakout | Long only | lookback, threshold |
mean_reversion | Long + Short | period, z_threshold |
momentum | Long only | period, threshold |
Configuration
Settings are loaded from ${CLAUDE_SKILL_DIR}/config/settings.yaml with this priority:
1. CLI arguments (highest) 2. settings.yaml values 3. Hardcoded defaults (lowest)
Risk management keys in settings.yaml:
risk:
max_position_size: 0.95 # fraction of cash per trade
stop_loss: 0.05 # 5% stop loss (null to disable)
take_profit: 0.15 # 15% take profit (null to disable)Output
Results are saved to ${CLAUDE_SKILL_DIR}/reports/:
*_summary.txt-- Performance metrics table*_trades.csv-- Trade log with entry/exit times, PnL*_equity.csv-- Equity curve data*_chart.png-- Equity curve + drawdown chart (requires matplotlib)
/compare-strategies
Run multiple strategies on the same data and compare performance side by side.
Usage
/compare-strategies <symbol> [strategies...] [options]Workflow
1. Fetch data once for the symbol and period 2. Run each strategy with its default (or specified) parameters 3. Collect results into a comparison table 4. Rank by chosen metric
Example Script
import sys
from pathlib import Path
sys.path.insert(0, str(Path("${CLAUDE_SKILL_DIR}/scripts")))
from backtest import load_data, run_backtest
from fetch_data import parse_period
from datetime import datetime
# Configuration
symbol = "BTC-USD"
strategies = ["sma_crossover", "ema_crossover", "rsi_reversal", "macd",
"bollinger_bands", "breakout", "mean_reversion", "momentum"]
end = datetime.now()
start = end - parse_period("1y")
# Load data once
data_dir = Path("${CLAUDE_SKILL_DIR}/data")
data = load_data(symbol, start, end, data_dir)
data.attrs["symbol"] = symbol
# Run all strategies
results = []
for name in strategies:
try:
result = run_backtest(strategy_name=name, data=data.copy())
results.append(result)
print(f"{name:20s} Return: {result.total_return:+8.2f}% "
f"Sharpe: {result.sharpe_ratio:6.2f} "
f"MaxDD: {result.max_drawdown:+8.2f}% "
f"Trades: {result.total_trades}")
except Exception as e:
print(f"{name:20s} ERROR: {e}")
# Sort by Sharpe ratio
results.sort(key=lambda r: r.sharpe_ratio, reverse=True)
print(f"\nBest strategy: {results[0].strategy} (Sharpe: {results[0].sharpe_ratio:.2f})")CLI Approach
Run individual backtests and compare the summary files:
for strategy in sma_crossover ema_crossover rsi_reversal macd bollinger_bands; do
python ${CLAUDE_SKILL_DIR}/scripts/backtest.py \
--strategy $strategy --symbol BTC-USD --period 1y --quiet
doneWhat to Compare
| Metric | What It Tells You |
|---|---|
| Sharpe Ratio | Best risk-adjusted return |
| Total Return | Raw profitability |
| Max Drawdown | Worst-case pain |
| Win Rate | Consistency |
| Profit Factor | Reward-to-risk per trade |
| Total Trades | Activity level / costs |
Tips
- Always compare on the same data range and capital
- A strategy with fewer trades but higher Sharpe may be preferable to one with high return but deep drawdowns
- Consider combining uncorrelated strategies for portfolio diversification
- Use
/optimize-parameterson the top 2-3 strategies for fine-tuning
/optimize-parameters
Find optimal strategy parameters via grid search over historical data.
Usage
/optimize-parameters <strategy_name> <symbol> <param_grid> [options]How It Works
The optimizer in ${CLAUDE_SKILL_DIR}/scripts/optimize.py runs a full backtest for every combination of parameters in your grid, ranks results by a target metric (default: Sharpe ratio), and reports the top performers.
Examples
Optimize SMA crossover periods:
python ${CLAUDE_SKILL_DIR}/scripts/optimize.py \
--strategy sma_crossover --symbol BTC-USD --period 2y \
--param-grid '{"fast_period": [10, 15, 20, 30], "slow_period": [50, 100, 150, 200]}'Optimize RSI thresholds targeting win rate:
python ${CLAUDE_SKILL_DIR}/scripts/optimize.py \
--strategy rsi_reversal --symbol ETH-USD --period 1y \
--param-grid '{"period": [7, 14, 21], "overbought": [65, 70, 75], "oversold": [25, 30, 35]}' \
--metric win_rateOptimize with custom capital and output:
python ${CLAUDE_SKILL_DIR}/scripts/optimize.py \
--strategy macd --symbol SOL-USD --period 1y \
--param-grid '{"fast": [8, 12, 16], "slow": [20, 26, 32], "signal": [7, 9, 11]}' \
--capital 50000 --output ./my-reportsOptions
| Flag | Description | Default |
|---|---|---|
--strategy | Strategy name | Required |
--symbol | Trading symbol | Required |
--param-grid | JSON dict of param lists | Required |
--period | Lookback period | 1y |
--start/--end | Explicit date range | -- |
--capital | Starting capital | 10000 |
--metric | Sort/rank metric | sharpe_ratio |
--output | Output directory | ${CLAUDE_SKILL_DIR}/reports |
Available Metrics
total_return, sharpe_ratio, sortino_ratio, max_drawdown, win_rate, profit_factor, calmar_ratio, total_trades
Output
- Console table of top 10 parameter combinations
optimization_<strategy>_<timestamp>.csv-- Full results gridoptimization_<strategy>_<timestamp>.txt-- Formatted summary
Tips
- Start with coarse grids, then narrow around promising values
- Use at least 1-2 years of data to avoid overfitting
- Watch for parameter sets that have very few trades (unreliable statistics)
- Compare in-sample optimization with out-of-sample validation (see
/walk-forward)
/walk-forward
Perform walk-forward analysis: optimize parameters on rolling in-sample windows, then validate on out-of-sample data to detect overfitting.
Usage
/walk-forward <strategy> <symbol> <param_grid> [options]How It Works
1. Divide historical data into rolling windows (e.g., 12-month in-sample + 3-month out-of-sample) 2. For each window: optimize parameters on in-sample data 3. Apply best parameters to the out-of-sample period 4. Stitch out-of-sample results together for a realistic performance estimate
Example Script
import sys
from pathlib import Path
from datetime import datetime, timedelta
sys.path.insert(0, str(Path("${CLAUDE_SKILL_DIR}/scripts")))
from backtest import load_data, run_backtest
from optimize import grid_search
from fetch_data import parse_period
# Configuration
symbol = "BTC-USD"
strategy = "sma_crossover"
param_grid = {"fast_period": [10, 20, 30], "slow_period": [50, 100, 200]}
in_sample_months = 12
out_of_sample_months = 3
total_years = 3
# Load full dataset
end = datetime.now()
start = end - parse_period(f"{total_years}y")
data_dir = Path("${CLAUDE_SKILL_DIR}/data")
full_data = load_data(symbol, start, end, data_dir)
full_data.attrs["symbol"] = symbol
# Walk-forward loop
window_start = start
oos_results = []
while window_start + timedelta(days=(in_sample_months + out_of_sample_months) * 30) <= end:
is_end = window_start + timedelta(days=in_sample_months * 30)
oos_end = is_end + timedelta(days=out_of_sample_months * 30)
# In-sample data
is_data = full_data[(full_data.index >= str(window_start.date())) &
(full_data.index < str(is_end.date()))].copy()
is_data.attrs["symbol"] = symbol
# Optimize on in-sample
opt_df = grid_search(strategy, is_data, param_grid)
if len(opt_df) == 0:
window_start = is_end
continue
best_params = {col: opt_df.iloc[0][col]
for col in param_grid.keys()}
# Out-of-sample data
oos_data = full_data[(full_data.index >= str(is_end.date())) &
(full_data.index < str(oos_end.date()))].copy()
oos_data.attrs["symbol"] = symbol
if len(oos_data) < 20:
window_start = is_end
continue
# Test on out-of-sample
oos_result = run_backtest(strategy, oos_data, params=best_params)
oos_results.append({
"window": f"{window_start.date()} to {oos_end.date()}",
"best_params": best_params,
"is_sharpe": opt_df.iloc[0].get("sharpe_ratio", 0),
"oos_return": oos_result.total_return,
"oos_sharpe": oos_result.sharpe_ratio,
"oos_max_dd": oos_result.max_drawdown,
})
print(f"Window {window_start.date()}-{oos_end.date()}: "
f"IS Sharpe={opt_df.iloc[0].get('sharpe_ratio', 0):.2f} "
f"OOS Return={oos_result.total_return:+.2f}% "
f"OOS Sharpe={oos_result.sharpe_ratio:.2f}")
window_start = is_end
# Summary
if oos_results:
avg_oos_return = sum(r["oos_return"] for r in oos_results) / len(oos_results)
avg_oos_sharpe = sum(r["oos_sharpe"] for r in oos_results) / len(oos_results)
print(f"\nWalk-Forward Summary ({len(oos_results)} windows):")
print(f" Avg OOS Return: {avg_oos_return:+.2f}%")
print(f" Avg OOS Sharpe: {avg_oos_sharpe:.2f}")Interpreting Results
| Signal | Meaning |
|---|---|
| IS Sharpe >> OOS Sharpe | Overfitting -- parameters don't generalize |
| IS Sharpe ~ OOS Sharpe | Robust -- strategy is stable |
| OOS returns consistently negative | Strategy doesn't work in live conditions |
| Optimal params change every window | Unstable strategy, avoid |
Tips
- Use at least 3-5 out-of-sample windows for statistical significance
- In-sample should be 3-4x longer than out-of-sample
- If walk-forward degrades badly vs. single-period optimization, the strategy is overfit
- Combine with
/compare-strategiesto find the most robust strategy first
# Backtesting Configuration
# Copy to settings.yaml and customize
# Data source settings
data:
provider: yfinance # Options: yfinance, coingecko
cache_dir: ./data # Where to cache downloaded data
default_interval: 1d # Default bar interval
# Backtest execution settings
backtest:
default_capital: 10000 # Starting capital in USD
commission: 0.001 # Commission per trade (0.1%)
slippage: 0.0005 # Slippage per trade (0.05%)
# Risk management (optional)
risk:
max_position_size: 0.95 # Max % of capital per position
stop_loss: null # Fixed stop loss % (null = disabled)
take_profit: null # Fixed take profit % (null = disabled)
# Report generation
reporting:
output_dir: ./reports # Where to save results
save_trades: true # Save trade log CSV
save_equity: true # Save equity curve CSV
save_chart: true # Generate equity chart PNG
# Strategy defaults (can be overridden via --params)
strategies:
sma_crossover:
fast_period: 20
slow_period: 50
ema_crossover:
fast_period: 12
slow_period: 26
rsi_reversal:
period: 14
overbought: 70
oversold: 30
macd:
fast: 12
slow: 26
signal: 9
bollinger_bands:
period: 20
std_dev: 2.0
breakout:
lookback: 20
threshold: 0.0
mean_reversion:
period: 20
z_threshold: 2.0
momentum:
period: 14
threshold: 5.0
Error Handling Reference
Data Fetching Errors
No Data Returned
Error: No data returned for {symbol}Causes:
- Invalid symbol format (use
BTC-USDnotBTC/USD) - Symbol not available on data provider
- Date range has no trading data
Solutions:
# Check valid symbol format for Yahoo Finance
python -c "import yfinance as yf; print(yf.Ticker('BTC-USD').info.get('symbol'))"
# Try CoinGecko for crypto
python scripts/fetch_data.py --symbol BTC --source coingeckoInsufficient Data
Error: Insufficient data. Got {n} bars, need at least 50.Cause: Date range too short or strategy lookback period exceeds data length.
Solution: Extend the period or reduce strategy lookback:
python scripts/backtest.py --strategy sma_crossover --period 1y # More datayfinance Not Installed
yfinance not installed. Install with: pip install yfinance pandasSolution:
pip install yfinance pandas numpy matplotlibStrategy Errors
Unknown Strategy
ValueError: Unknown strategy: {name}. Available: [...]Solution: Use --list to see available strategies:
python scripts/backtest.py --listInvalid Parameters JSON
json.decoder.JSONDecodeError: ...Cause: Malformed JSON in --params argument.
Solution: Ensure proper JSON format:
# Correct
--params '{"fast_period": 20, "slow_period": 50}'
# Wrong (single quotes inside)
--params "{'fast_period': 20}"Strategy Lookback Exceeded
Signal generation failed: insufficient data for lookback periodCause: Strategy needs more historical bars than available.
Solution: Fetch more data or use shorter lookback:
python scripts/fetch_data.py --symbol BTC-USD --period 2yCalculation Errors
Division by Zero in Metrics
RuntimeWarning: divide by zero encounteredCause: No trades generated, or all trades were losses.
Solution: This is informational. Check if strategy generates signals:
- Too few signals = parameters may be too restrictive
- No winning trades = strategy may not suit the asset/timeframe
NaN in Results
Sharpe Ratio: nanCause: Zero variance in returns (e.g., all flat periods).
Solution: Use longer test period or more volatile asset.
File/Directory Errors
Permission Denied
PermissionError: [Errno 13] Permission denied: 'reports/...'Solution:
chmod -R u+w /path/to/backtester/reports/Missing Directory
FileNotFoundError: [Errno 2] No such file or directory: 'data/...'Solution: Directories are auto-created, but ensure write permissions:
mkdir -p data reportsOptimization Errors
Memory Error During Grid Search
MemoryError: Unable to allocate arrayCause: Too many parameter combinations.
Solution: Reduce parameter grid:
# Instead of testing 10x10x10 = 1000 combinations
--param-grid '{"p1": [10,20,30], "p2": [50,100]}' # 6 combinationsOptimization Takes Too Long
Cause: Large grid + large dataset.
Solutions:
1. Reduce parameter grid 2. Use shorter test period for initial optimization 3. Parallelize (not implemented in basic version)
Performance Warnings
Unrealistic Results
Symptoms:
- Sharpe ratio > 5
- Win rate > 80%
- No losing periods
Cause: Likely overfitting or look-ahead bias.
Solution:
- Test on out-of-sample data
- Add realistic commission/slippage
- Verify signal generation doesn't use future data
All Trades Are Losses
Cause:
- Commission/slippage too high
- Strategy not suited for asset
- Wrong direction (buying when should sell)
Solution:
- Reduce costs:
--commission 0.0005 --slippage 0.0002 - Try different strategy
- Check strategy logic
--- [Tons of Skills](https://tonsofskills.com) by [Intent Solutions](https://intentsolutions.io) | [jeremylongshore.com](https://jeremylongshore.com)
Backtesting Examples
Example 1: Basic SMA Crossover Backtest
Test a simple moving average crossover strategy on Bitcoin:
python scripts/backtest.py \
--strategy sma_crossover \
--symbol BTC-USD \
--period 1y \
--capital 10000 \
--params '{"fast_period": 20, "slow_period": 50}'Expected Output:
╔══════════════════════════════════════════════════════════════════════╗
║ BACKTEST RESULTS: SMA_CROSSOVER ║
║ BTC-USD | 2023-01-14 to 2024-01-14 ║
╠══════════════════════════════════════════════════════════════════════╣
║ Total Return: +47.32% │ Max Drawdown: -18.45% ║
║ Sharpe Ratio: 1.87 │ Win Rate: 58.3% ║
╚══════════════════════════════════════════════════════════════════════╝Example 2: RSI Reversal Strategy
Test an RSI mean reversion strategy on Ethereum:
python scripts/backtest.py \
--strategy rsi_reversal \
--symbol ETH-USD \
--start 2023-01-01 \
--end 2024-01-01 \
--capital 25000 \
--params '{"period": 14, "overbought": 70, "oversold": 30}'Example 3: MACD with Custom Costs
Test MACD on Solana with realistic exchange fees:
python scripts/backtest.py \
--strategy macd \
--symbol SOL-USD \
--period 6m \
--capital 5000 \
--commission 0.002 \
--slippage 0.001 \
--params '{"fast": 12, "slow": 26, "signal": 9}'Example 4: Parameter Optimization
Find optimal SMA crossover parameters:
python scripts/optimize.py \
--strategy sma_crossover \
--symbol BTC-USD \
--period 2y \
--param-grid '{"fast_period": [10, 20, 30, 50], "slow_period": [50, 100, 150, 200]}'Expected Output:
================================================================================
PARAMETER OPTIMIZATION RESULTS
================================================================================
TOP 10 PARAMETER COMBINATIONS (by Sharpe Ratio):
--------------------------------------------------------------------------------
fast_period slow_period Return% Sharpe MaxDD% WinRate% Trades
--------------------------------------------------------------------------------
20 100 52.3 2.14 -15.2 62.5 18
30 150 48.7 1.98 -12.8 58.3 14
...
BEST PARAMETERS:
fast_period: 20
slow_period: 100
================================================================================Example 5: Compare Multiple Strategies
Test different strategies on the same data:
# Fetch data once
python scripts/fetch_data.py --symbol BTC-USD --period 1y
# Run each strategy
for strategy in sma_crossover ema_crossover rsi_reversal macd bollinger_bands; do
python scripts/backtest.py --strategy $strategy --symbol BTC-USD --period 1y --quiet
doneExample 6: Bollinger Bands Mean Reversion
python scripts/backtest.py \
--strategy bollinger_bands \
--symbol ETH-USD \
--period 1y \
--params '{"period": 20, "std_dev": 2.0}'Example 7: Breakout Strategy
python scripts/backtest.py \
--strategy breakout \
--symbol BTC-USD \
--period 6m \
--params '{"lookback": 20, "threshold": 1.0}'Example 8: List Available Strategies
python scripts/backtest.py --listOutput:
Available strategies:
sma_crossover: Simple Moving Average Crossover Strategy.
ema_crossover: Exponential Moving Average Crossover Strategy.
rsi_reversal: RSI Overbought/Oversold Reversal Strategy.
macd: MACD Signal Line Crossover Strategy.
bollinger_bands: Bollinger Bands Mean Reversion Strategy.
breakout: Price Breakout Strategy.
mean_reversion: Mean Reversion Strategy.
momentum: Rate of Change Momentum Strategy.Example 9: Walk-Forward Analysis
Test strategy on rolling windows:
# Train on 2022, test on 2023
python scripts/backtest.py \
--strategy sma_crossover \
--symbol BTC-USD \
--start 2023-01-01 \
--end 2023-12-31 \
--params '{"fast_period": 20, "slow_period": 100}' # From 2022 optimizationExample 10: Multi-Asset Portfolio
Test same strategy across multiple assets:
for symbol in BTC-USD ETH-USD SOL-USD AVAX-USD; do
echo "=== $symbol ==="
python scripts/backtest.py \
--strategy sma_crossover \
--symbol $symbol \
--period 1y \
--quiet
done--- [Tons of Skills](https://tonsofskills.com) by [Intent Solutions](https://intentsolutions.io) | [jeremylongshore.com](https://jeremylongshore.com)
Implementation Guide
Overview
This guide covers implementing and extending the backtesting system.
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ fetch_data │────▶│ backtest │────▶│ metrics │
│ (data layer) │ │ (engine) │ │ (analysis) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ strategies │
│ (signal gen) │
└─────────────────┘Step 1: Install Dependencies
pip install pandas numpy yfinance matplotlib
# Optional for advanced features:
pip install ta-lib scipy scikit-learnStep 2: Fetch Historical Data
# Fetch 2 years of daily BTC data
python scripts/fetch_data.py --symbol BTC-USD --period 2y --interval 1d
# Fetch specific date range
python scripts/fetch_data.py --symbol ETH-USD --start 2022-01-01 --end 2024-01-01
# Use CoinGecko for crypto (no Yahoo Finance ticker needed)
python scripts/fetch_data.py --symbol BTC --period 1y --source coingeckoData is cached in data/{symbol}_{interval}.csv.
Step 3: Run Backtest
# Basic backtest
python scripts/backtest.py --strategy sma_crossover --symbol BTC-USD --period 1y
# With custom parameters
python scripts/backtest.py \
--strategy rsi_reversal \
--symbol ETH-USD \
--period 6m \
--capital 25000 \
--params '{"period": 14, "overbought": 75, "oversold": 25}'
# Custom commission and slippage
python scripts/backtest.py \
--strategy macd \
--symbol SOL-USD \
--period 1y \
--commission 0.002 \
--slippage 0.001Step 4: Optimize Parameters
# Grid search for SMA crossover
python scripts/optimize.py \
--strategy sma_crossover \
--symbol BTC-USD \
--period 1y \
--param-grid '{"fast_period": [10, 20, 30, 50], "slow_period": [50, 100, 150, 200]}'
# Optimize RSI parameters
python scripts/optimize.py \
--strategy rsi_reversal \
--symbol ETH-USD \
--param-grid '{"period": [7, 14, 21], "overbought": [70, 75, 80], "oversold": [20, 25, 30]}'Step 5: Analyze Results
Results are saved to reports/ directory:
*_summary.txt- Performance metrics table*_trades.csv- Trade log with entry/exit details*_equity.csv- Equity curve data*_chart.png- Visual equity curve and drawdown
Adding Custom Strategies
Create a new strategy by extending the base class:
# In scripts/strategies.py
class MyCustomStrategy(Strategy):
"""My custom trading strategy."""
name = "my_strategy"
lookback = 50 # Minimum bars needed
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
# Your signal logic here
threshold = params.get("threshold", 0.02)
close = data["close"]
returns = close.pct_change()
# Example: buy after big drop, sell after big gain
if returns.iloc[-1] < -threshold:
return Signal(entry=True, direction="long")
elif returns.iloc[-1] > threshold:
return Signal(exit=True)
return Signal()
# Register in STRATEGIES dict
STRATEGIES["my_strategy"] = MyCustomStrategy()Configuration Options
Create config/settings.yaml:
data:
provider: yfinance
cache_dir: ./data
default_interval: 1d
backtest:
default_capital: 10000
commission: 0.001 # 0.1% per trade
slippage: 0.0005 # 0.05% slippage
risk:
max_position_size: 0.95 # 95% of capital
stop_loss: null # Optional fixed stop loss
take_profit: null # Optional fixed take profit
reporting:
output_dir: ./reports
save_trades: true
save_equity: true
save_chart: truePerformance Tips
1. Cache data: Fetch once, reuse for multiple backtests 2. Use appropriate intervals: Daily for swing trading, hourly for day trading 3. Test on out-of-sample data: Split data into train/test periods 4. Watch for overfitting: Simpler strategies often generalize better 5. Account for costs: Commission + slippage can erode profits significantly
--- [Tons of Skills](https://tonsofskills.com) by [Intent Solutions](https://intentsolutions.io) | [jeremylongshore.com](https://jeremylongshore.com)
#!/usr/bin/env python3
"""
Main Backtesting Engine
Run trading strategy backtests with performance analysis.
Usage:
python backtest.py --strategy sma_crossover --symbol BTC-USD --period 1y
python backtest.py --strategy rsi_reversal --symbol ETH-USD --start 2023-01-01 --end 2024-01-01
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Any, List
import pandas as pd
try:
import yaml
except ImportError:
yaml = None
# Add script directory to path
sys.path.insert(0, str(Path(__file__).parent))
from strategies import get_strategy, list_strategies
from metrics import Trade, BacktestResult, calculate_all_metrics, format_results
from fetch_data import parse_period
def load_settings(skill_dir: Path) -> dict:
"""Load settings from config/settings.yaml if present, else return defaults."""
settings_file = skill_dir / "config" / "settings.yaml"
if yaml is not None and settings_file.exists():
with open(settings_file) as f:
return yaml.safe_load(f) or {}
return {}
def load_data(symbol: str, start: datetime, end: datetime, data_dir: Path) -> pd.DataFrame:
"""Load price data from CSV or fetch if not cached."""
# Try to load from cache
cache_file = data_dir / f"{symbol.replace('/', '_').replace('-', '_')}_1d.csv"
if cache_file.exists():
df = pd.read_csv(cache_file, parse_dates=["date"], index_col="date")
# Remove timezone info for comparison
if df.index.tz is not None:
df.index = df.index.tz_localize(None)
df = df[(df.index >= pd.Timestamp(start)) & (df.index <= pd.Timestamp(end))]
if len(df) > 0:
return df
# Fetch using yfinance
try:
import yfinance as yf
ticker = yf.Ticker(symbol)
df = ticker.history(start=start, end=end, interval="1d")
df.columns = [c.lower() for c in df.columns]
df.index.name = "date"
# Remove timezone for consistency
if df.index.tz is not None:
df.index = df.index.tz_localize(None)
# Cache the data
data_dir.mkdir(parents=True, exist_ok=True)
df.to_csv(cache_file)
return df
except ImportError:
print("yfinance not installed. Install with: pip install yfinance")
sys.exit(1)
except Exception as e:
print(f"Error fetching data for {symbol}: {e}")
sys.exit(1)
def run_backtest(
strategy_name: str,
data: pd.DataFrame,
initial_capital: float = 10000,
params: Dict[str, Any] = None,
commission: float = 0.001,
slippage: float = 0.0005,
risk_settings: Dict[str, Any] = None,
) -> BacktestResult:
"""Run a backtest on historical data.
Args:
risk_settings: Dict with optional keys:
- stop_loss: float or None, max loss % before forced exit (e.g. 0.05 = 5%)
- take_profit: float or None, profit % target for forced exit
- max_position_size: float, fraction of cash to allocate (default 0.95)
"""
params = params or {}
risk_settings = risk_settings or {}
strategy = get_strategy(strategy_name)
stop_loss = risk_settings.get("stop_loss")
take_profit = risk_settings.get("take_profit")
max_position_size = risk_settings.get("max_position_size", 0.95)
trades: List[Trade] = []
equity = [initial_capital]
cash = initial_capital
position = None
position_size = 0
for i in range(strategy.lookback, len(data)):
# Get data slice up to current bar
slice_data = data.iloc[: i + 1].copy()
current_bar = data.iloc[i]
current_price = current_bar["close"]
current_time = data.index[i]
# Generate signals
signal = strategy.generate_signals(slice_data, params)
# Apply slippage
buy_price = current_price * (1 + slippage)
sell_price = current_price * (1 - slippage)
# --- Check stop-loss / take-profit ---
force_exit = False
if position is not None:
if position["direction"] == "long":
unrealized_pnl_pct = (current_price - position["entry_price"]) / position["entry_price"]
else:
unrealized_pnl_pct = (position["entry_price"] - current_price) / position["entry_price"]
if stop_loss is not None and unrealized_pnl_pct <= -stop_loss:
force_exit = True
elif take_profit is not None and unrealized_pnl_pct >= take_profit:
force_exit = True
# --- Exit logic (checked before entry to allow same-bar flips) ---
if position is not None and (signal.exit or force_exit):
if position["direction"] == "long":
exit_value = position_size * sell_price
commission_cost = exit_value * commission
cash += exit_value - commission_cost
trade_exit_price = sell_price
else: # short
pnl = position_size * (position["entry_price"] - buy_price)
commission_cost = position_size * buy_price * commission
cash += position["collateral"] + pnl - commission_cost
trade_exit_price = buy_price
trade = Trade(
entry_time=position["entry_time"],
exit_time=current_time,
entry_price=position["entry_price"],
exit_price=trade_exit_price,
direction=position["direction"],
size=position["size"],
)
trades.append(trade)
position = None
position_size = 0
# --- Entry logic (separate if, enables same-bar exit+entry) ---
if signal.entry and position is None:
if signal.direction == "long":
position_value = cash * max_position_size
position_size = position_value / buy_price
commission_cost = position_value * commission
cash -= position_value + commission_cost
position = {
"entry_time": current_time,
"entry_price": buy_price,
"direction": "long",
"size": position_size,
}
else: # short
position_value = cash * max_position_size
position_size = position_value / sell_price
commission_cost = position_value * commission
cash -= position_value + commission_cost
position = {
"entry_time": current_time,
"entry_price": sell_price,
"direction": "short",
"size": position_size,
"collateral": position_value,
}
# Calculate equity (cash + position value)
if position is not None:
if position["direction"] == "long":
equity.append(cash + position_size * current_price)
else:
equity.append(cash + position["collateral"] + position_size * (position["entry_price"] - current_price))
else:
equity.append(cash)
# Close any open position at end
if position is not None:
if position["direction"] == "long":
final_price = data.iloc[-1]["close"] * (1 - slippage)
exit_value = position_size * final_price
commission_cost = exit_value * commission
cash += exit_value - commission_cost
trade_exit_price = final_price
else:
final_price = data.iloc[-1]["close"] * (1 + slippage)
pnl = position_size * (position["entry_price"] - final_price)
commission_cost = position_size * final_price * commission
cash += position["collateral"] + pnl - commission_cost
trade_exit_price = final_price
trade = Trade(
entry_time=position["entry_time"],
exit_time=data.index[-1],
entry_price=position["entry_price"],
exit_price=trade_exit_price,
direction=position["direction"],
size=position["size"],
)
trades.append(trade)
equity[-1] = cash
# Create equity curve
equity_curve = pd.Series(equity, index=data.index[strategy.lookback - 1 :])
# Build result
result = BacktestResult(
strategy=strategy_name,
symbol=data.attrs.get("symbol", "Unknown"),
start_date=data.index[0],
end_date=data.index[-1],
initial_capital=initial_capital,
final_capital=equity[-1],
trades=trades,
equity_curve=equity_curve,
parameters=params,
)
# Calculate all metrics
result = calculate_all_metrics(result)
return result
def save_results(result: BacktestResult, output_dir: Path) -> None:
"""Save backtest results to files."""
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = f"{result.strategy}_{result.symbol.replace('/', '_')}_{timestamp}"
# Save summary
summary_file = output_dir / f"{base_name}_summary.txt"
with open(summary_file, "w") as f:
f.write(format_results(result))
# Save trades to CSV
if result.trades:
trades_file = output_dir / f"{base_name}_trades.csv"
trades_df = pd.DataFrame(
[
{
"entry_time": t.entry_time,
"exit_time": t.exit_time,
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"direction": t.direction,
"size": t.size,
"pnl": t.pnl,
"pnl_pct": t.pnl_pct,
"duration": t.duration,
}
for t in result.trades
]
)
trades_df.to_csv(trades_file, index=False)
# Save equity curve
equity_file = output_dir / f"{base_name}_equity.csv"
result.equity_curve.to_csv(equity_file, header=["equity"])
# Try to plot equity curve
try:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
# Equity curve
axes[0].plot(result.equity_curve, label="Portfolio Value", color="blue")
axes[0].set_title(f"{result.strategy.upper()} - {result.symbol} Equity Curve")
axes[0].set_ylabel("Portfolio Value ($)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Drawdown
rolling_max = result.equity_curve.expanding().max()
drawdown = (result.equity_curve - rolling_max) / rolling_max * 100
axes[1].fill_between(drawdown.index, drawdown, 0, alpha=0.5, color="red")
axes[1].set_title("Drawdown")
axes[1].set_ylabel("Drawdown (%)")
axes[1].set_xlabel("Date")
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
chart_file = output_dir / f"{base_name}_chart.png"
plt.savefig(chart_file, dpi=100)
plt.close()
print(f"Chart saved to: {chart_file}")
except ImportError:
pass # matplotlib not available
print(f"Results saved to: {output_dir}")
def main():
parser = argparse.ArgumentParser(description="Backtest trading strategies")
parser.add_argument("--strategy", "-s", required=True, help="Strategy name")
parser.add_argument("--symbol", required=True, help="Trading symbol (e.g., BTC-USD)")
parser.add_argument("--period", "-p", help="Lookback period (e.g., 1y, 6m, 30d)")
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
parser.add_argument("--capital", "-c", type=float, default=None, help="Initial capital")
parser.add_argument("--params", help="Strategy parameters as JSON")
parser.add_argument("--commission", type=float, default=None, help="Commission per trade")
parser.add_argument("--slippage", type=float, default=None, help="Slippage per trade")
parser.add_argument("--output", "-o", help="Output directory")
parser.add_argument("--list", action="store_true", help="List available strategies")
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal output")
args = parser.parse_args()
# List strategies
if args.list:
print("Available strategies:")
for name, desc in list_strategies().items():
print(f" {name}: {desc}")
return
# Load settings: CLI args > settings.yaml > hardcoded defaults
script_dir = Path(__file__).parent.parent
settings = load_settings(script_dir)
backtest_settings = settings.get("backtest", {})
risk_settings = settings.get("risk", {})
capital = args.capital if args.capital is not None else backtest_settings.get("default_capital", 10000)
commission = args.commission if args.commission is not None else backtest_settings.get("commission", 0.001)
slippage = args.slippage if args.slippage is not None else backtest_settings.get("slippage", 0.0005)
# Determine date range
if args.start and args.end:
start = datetime.strptime(args.start, "%Y-%m-%d")
end = datetime.strptime(args.end, "%Y-%m-%d")
elif args.period:
end = datetime.now()
start = end - parse_period(args.period)
else:
end = datetime.now()
start = end - timedelta(days=365)
# Parse parameters
params = json.loads(args.params) if args.params else {}
# Set up directories
data_dir = script_dir / "data"
output_dir = Path(args.output) if args.output else script_dir / "reports"
# Load data
if not args.quiet:
print(f"Loading data for {args.symbol} from {start.date()} to {end.date()}...")
data = load_data(args.symbol, start, end, data_dir)
data.attrs["symbol"] = args.symbol
if len(data) < 50:
print(f"Error: Insufficient data. Got {len(data)} bars, need at least 50.")
sys.exit(1)
if not args.quiet:
print(f"Loaded {len(data)} bars")
print(f"Running backtest with {args.strategy} strategy...")
# Run backtest
result = run_backtest(
strategy_name=args.strategy,
data=data,
initial_capital=capital,
params=params,
commission=commission,
slippage=slippage,
risk_settings=risk_settings,
)
# Print results
print(format_results(result))
# Save results
save_results(result, output_dir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Historical Data Fetcher
Fetch and cache price data from various sources.
Usage:
python fetch_data.py --symbol BTC-USD --period 2y --interval 1d
python fetch_data.py --symbol ETH-USD --start 2020-01-01 --end 2024-01-01
"""
from __future__ import annotations
import argparse
from datetime import datetime, timedelta
from pathlib import Path
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pandas as pd # noqa: F401 — used in deferred type hints
def parse_period(period: str) -> timedelta:
"""Parse period string like '1y', '6m', '30d'."""
unit = period[-1].lower()
value = int(period[:-1])
if unit == "y":
return timedelta(days=value * 365)
elif unit == "m":
return timedelta(days=value * 30)
elif unit == "d":
return timedelta(days=value)
elif unit == "w":
return timedelta(weeks=value)
else:
raise ValueError(f"Unknown period unit: {unit}")
def fetch_yfinance(symbol: str, start: datetime, end: datetime, interval: str) -> "pd.DataFrame":
"""Fetch data from Yahoo Finance."""
try:
import yfinance as yf
except ImportError:
print("yfinance not installed. Install with: pip install yfinance pandas")
sys.exit(1)
print(f"Fetching {symbol} from Yahoo Finance...")
ticker = yf.Ticker(symbol)
df = ticker.history(start=start, end=end, interval=interval)
if df.empty:
raise ValueError(f"No data returned for {symbol}")
df.columns = [c.lower() for c in df.columns]
df.index.name = "date"
return df
def fetch_coingecko(symbol: str, days: int) -> "pd.DataFrame":
"""Fetch data from CoinGecko (crypto only)."""
try:
import requests
import pandas as pd
except ImportError:
print("requests/pandas not installed")
sys.exit(1)
# Map common symbols to CoinGecko IDs
symbol_map = {
"BTC": "bitcoin",
"ETH": "ethereum",
"SOL": "solana",
"AVAX": "avalanche-2",
"MATIC": "matic-network",
"DOT": "polkadot",
"LINK": "chainlink",
"UNI": "uniswap",
"AAVE": "aave",
}
coin_id = symbol_map.get(symbol.split("-")[0].upper(), symbol.lower())
print(f"Fetching {coin_id} from CoinGecko...")
url = f"https://api.coingecko.com/api/v3/coins/{coin_id}/market_chart"
params = {"vs_currency": "usd", "days": days}
response = requests.get(url, params=params)
response.raise_for_status()
data = response.json()
df = pd.DataFrame(data["prices"], columns=["timestamp", "close"])
df["date"] = pd.to_datetime(df["timestamp"], unit="ms")
df.set_index("date", inplace=True)
df.drop("timestamp", axis=1, inplace=True)
# Add OHLV columns (approximations for daily)
df["open"] = df["close"].shift(1).fillna(df["close"])
df["high"] = df["close"] * 1.01 # Rough estimate
df["low"] = df["close"] * 0.99
df["volume"] = 0 # Not available in basic API
return df[["open", "high", "low", "close", "volume"]]
def main():
parser = argparse.ArgumentParser(description="Fetch historical price data")
parser.add_argument("--symbol", "-s", required=True, help="Trading symbol")
parser.add_argument("--period", "-p", help="Lookback period (e.g., 2y, 6m)")
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
parser.add_argument("--interval", "-i", default="1d", help="Data interval (1d, 1h, etc.)")
parser.add_argument("--source", default="yfinance", choices=["yfinance", "coingecko"])
parser.add_argument("--output", "-o", help="Output directory")
args = parser.parse_args()
# Determine date range
if args.start and args.end:
start = datetime.strptime(args.start, "%Y-%m-%d")
end = datetime.strptime(args.end, "%Y-%m-%d")
elif args.period:
end = datetime.now()
start = end - parse_period(args.period)
else:
end = datetime.now()
start = end - timedelta(days=730) # 2 years default
# Fetch data
if args.source == "yfinance":
df = fetch_yfinance(args.symbol, start, end, args.interval)
else:
days = (end - start).days
df = fetch_coingecko(args.symbol, days)
# Save to file
script_dir = Path(__file__).parent.parent
output_dir = Path(args.output) if args.output else script_dir / "data"
output_dir.mkdir(parents=True, exist_ok=True)
filename = f"{args.symbol.replace('/', '_').replace('-', '_')}_{args.interval}.csv"
output_file = output_dir / filename
df.to_csv(output_file)
print(f"Data saved to: {output_file}")
print(f" Rows: {len(df)}")
print(f" Date range: {df.index[0]} to {df.index[-1]}")
print(f" Columns: {list(df.columns)}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Performance and Risk Metrics for Backtesting
"""
import numpy as np
import pandas as pd
from typing import List, Dict, Any
from dataclasses import dataclass
@dataclass
class Trade:
"""Represents a completed trade."""
entry_time: pd.Timestamp
exit_time: pd.Timestamp
entry_price: float
exit_price: float
direction: str # "long" or "short"
size: float
pnl: float = 0.0
pnl_pct: float = 0.0
duration: pd.Timedelta = None
def __post_init__(self):
if self.direction == "long":
self.pnl = (self.exit_price - self.entry_price) * self.size
self.pnl_pct = (self.exit_price - self.entry_price) / self.entry_price * 100
else:
self.pnl = (self.entry_price - self.exit_price) * self.size
self.pnl_pct = (self.entry_price - self.exit_price) / self.entry_price * 100
self.duration = self.exit_time - self.entry_time
@dataclass
class BacktestResult:
"""Complete backtest results."""
strategy: str
symbol: str
start_date: pd.Timestamp
end_date: pd.Timestamp
initial_capital: float
final_capital: float
trades: List[Trade]
equity_curve: pd.Series
parameters: Dict[str, Any]
# Performance metrics
total_return: float = 0.0
cagr: float = 0.0
sharpe_ratio: float = 0.0
sortino_ratio: float = 0.0
calmar_ratio: float = 0.0
# Risk metrics
max_drawdown: float = 0.0
max_drawdown_duration: int = 0
volatility: float = 0.0
var_95: float = 0.0
cvar_95: float = 0.0
ulcer_index: float = 0.0
# Trade statistics
total_trades: int = 0
win_rate: float = 0.0
profit_factor: float = 0.0
avg_win: float = 0.0
avg_loss: float = 0.0
expectancy: float = 0.0
max_consecutive_wins: int = 0
max_consecutive_losses: int = 0
avg_trade_duration: str = ""
def calculate_returns(equity_curve: pd.Series) -> pd.Series:
"""Calculate daily returns from equity curve."""
return equity_curve.pct_change().dropna()
def calculate_total_return(initial: float, final: float) -> float:
"""Calculate total return percentage."""
return ((final - initial) / initial) * 100
def calculate_cagr(initial: float, final: float, years: float) -> float:
"""Calculate Compound Annual Growth Rate."""
if years <= 0 or initial <= 0:
return 0.0
return ((final / initial) ** (1 / years) - 1) * 100
def calculate_sharpe_ratio(returns: pd.Series, risk_free_rate: float = 0.02) -> float:
"""Calculate annualized Sharpe Ratio.
Sharpe = (Return - Risk Free Rate) / Volatility
"""
if len(returns) < 2 or returns.std() == 0:
return 0.0
# Annualize
annual_return = returns.mean() * 252
annual_vol = returns.std() * np.sqrt(252)
return (annual_return - risk_free_rate) / annual_vol
def calculate_sortino_ratio(returns: pd.Series, risk_free_rate: float = 0.02) -> float:
"""Calculate Sortino Ratio (uses downside deviation only)."""
if len(returns) < 2:
return 0.0
downside_returns = returns[returns < 0]
if len(downside_returns) == 0 or downside_returns.std() == 0:
return float("inf") if returns.mean() > 0 else 0.0
annual_return = returns.mean() * 252
downside_std = downside_returns.std() * np.sqrt(252)
return (annual_return - risk_free_rate) / downside_std
def calculate_max_drawdown(equity_curve: pd.Series) -> tuple:
"""Calculate maximum drawdown and its duration.
Returns: (max_drawdown_pct, max_drawdown_duration_days)
"""
if len(equity_curve) < 2:
return 0.0, 0
rolling_max = equity_curve.expanding().max()
drawdowns = (equity_curve - rolling_max) / rolling_max * 100
max_dd = drawdowns.min()
# Calculate duration
in_drawdown = drawdowns < 0
if not in_drawdown.any():
return 0.0, 0
# Find longest drawdown period
drawdown_periods = []
start = None
for i, is_dd in enumerate(in_drawdown):
if is_dd and start is None:
start = i
elif not is_dd and start is not None:
drawdown_periods.append(i - start)
start = None
if start is not None:
drawdown_periods.append(len(in_drawdown) - start)
max_duration = max(drawdown_periods) if drawdown_periods else 0
return max_dd, max_duration
def calculate_calmar_ratio(cagr: float, max_drawdown: float) -> float:
"""Calculate Calmar Ratio = CAGR / |Max Drawdown|."""
if max_drawdown == 0:
return 0.0
return cagr / abs(max_drawdown)
def calculate_var(returns: pd.Series, confidence: float = 0.95) -> float:
"""Calculate Value at Risk at given confidence level."""
if len(returns) < 10:
return 0.0
return np.percentile(returns, (1 - confidence) * 100)
def calculate_cvar(returns: pd.Series, confidence: float = 0.95) -> float:
"""Calculate Conditional VaR (Expected Shortfall)."""
var = calculate_var(returns, confidence)
return returns[returns <= var].mean() if len(returns[returns <= var]) > 0 else var
def calculate_volatility(returns: pd.Series) -> float:
"""Calculate annualized volatility."""
return returns.std() * np.sqrt(252) * 100
def calculate_ulcer_index(equity_curve: pd.Series) -> float:
"""Calculate Ulcer Index (duration-weighted drawdown)."""
if len(equity_curve) < 2:
return 0.0
rolling_max = equity_curve.expanding().max()
drawdowns = ((equity_curve - rolling_max) / rolling_max * 100) ** 2
return np.sqrt(drawdowns.mean())
def calculate_trade_stats(trades: List[Trade]) -> Dict[str, Any]:
"""Calculate trade statistics."""
if not trades:
return {
"total_trades": 0,
"win_rate": 0.0,
"profit_factor": 0.0,
"avg_win": 0.0,
"avg_loss": 0.0,
"expectancy": 0.0,
"max_consecutive_wins": 0,
"max_consecutive_losses": 0,
"avg_trade_duration": "0d",
}
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl < 0]
total_trades = len(trades)
win_rate = len(wins) / total_trades * 100 if total_trades > 0 else 0
gross_profit = sum(t.pnl for t in wins) if wins else 0
gross_loss = abs(sum(t.pnl for t in losses)) if losses else 0
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
avg_win = np.mean([t.pnl for t in wins]) if wins else 0
avg_loss = np.mean([t.pnl for t in losses]) if losses else 0
# Expectancy = (Win% * Avg Win) - (Loss% * |Avg Loss|)
expectancy = (win_rate / 100 * avg_win) - ((1 - win_rate / 100) * abs(avg_loss))
# Consecutive wins/losses
max_consec_wins = 0
max_consec_losses = 0
current_wins = 0
current_losses = 0
for trade in trades:
if trade.pnl > 0:
current_wins += 1
current_losses = 0
max_consec_wins = max(max_consec_wins, current_wins)
else:
current_losses += 1
current_wins = 0
max_consec_losses = max(max_consec_losses, current_losses)
# Average duration
durations = [t.duration.days for t in trades if t.duration]
avg_duration = f"{np.mean(durations):.1f}d" if durations else "0d"
return {
"total_trades": total_trades,
"win_rate": win_rate,
"profit_factor": profit_factor,
"avg_win": avg_win,
"avg_loss": avg_loss,
"expectancy": expectancy,
"max_consecutive_wins": max_consec_wins,
"max_consecutive_losses": max_consec_losses,
"avg_trade_duration": avg_duration,
}
def calculate_all_metrics(result: BacktestResult) -> BacktestResult:
"""Calculate all performance and risk metrics for a backtest result."""
returns = calculate_returns(result.equity_curve)
years = (result.end_date - result.start_date).days / 365.25
# Performance metrics
result.total_return = calculate_total_return(result.initial_capital, result.final_capital)
result.cagr = calculate_cagr(result.initial_capital, result.final_capital, years)
result.sharpe_ratio = calculate_sharpe_ratio(returns)
result.sortino_ratio = calculate_sortino_ratio(returns)
# Risk metrics
result.max_drawdown, result.max_drawdown_duration = calculate_max_drawdown(result.equity_curve)
result.calmar_ratio = calculate_calmar_ratio(result.cagr, result.max_drawdown)
result.volatility = calculate_volatility(returns)
result.var_95 = calculate_var(returns, 0.95) * 100
result.cvar_95 = calculate_cvar(returns, 0.95) * 100
result.ulcer_index = calculate_ulcer_index(result.equity_curve)
# Trade statistics
trade_stats = calculate_trade_stats(result.trades)
result.total_trades = trade_stats["total_trades"]
result.win_rate = trade_stats["win_rate"]
result.profit_factor = trade_stats["profit_factor"]
result.avg_win = trade_stats["avg_win"]
result.avg_loss = trade_stats["avg_loss"]
result.expectancy = trade_stats["expectancy"]
result.max_consecutive_wins = trade_stats["max_consecutive_wins"]
result.max_consecutive_losses = trade_stats["max_consecutive_losses"]
result.avg_trade_duration = trade_stats["avg_trade_duration"]
return result
def format_results(result: BacktestResult) -> str:
"""Format backtest results as ASCII table."""
params_str = ", ".join(f"{k}={v}" for k, v in result.parameters.items())
return f"""
╔══════════════════════════════════════════════════════════════════════╗
║ BACKTEST RESULTS: {result.strategy.upper():^20} ║
║ {result.symbol} | {result.start_date.strftime("%Y-%m-%d")} to {result.end_date.strftime("%Y-%m-%d")} ║
╠══════════════════════════════════════════════════════════════════════╣
║ PERFORMANCE │ RISK ║
║ ─────────────────────────────────────┼───────────────────────────── ║
║ Total Return: {result.total_return:>+10.2f}% │ Max Drawdown: {result.max_drawdown:>+10.2f}% ║
║ CAGR: {result.cagr:>+10.2f}% │ VaR (95%): {result.var_95:>+10.2f}% ║
║ Sharpe Ratio: {result.sharpe_ratio:>10.2f} │ Volatility: {result.volatility:>10.2f}% ║
║ Sortino Ratio: {result.sortino_ratio:>10.2f} │ Ulcer Index: {result.ulcer_index:>10.2f} ║
║ Calmar Ratio: {result.calmar_ratio:>10.2f} │ CVaR (95%): {result.cvar_95:>+10.2f}% ║
╠══════════════════════════════════════════════════════════════════════╣
║ TRADE STATISTICS ║
║ ─────────────────────────────────────────────────────────────────────║
║ Total Trades: {result.total_trades:>10} │ Profit Factor: {result.profit_factor:>10.2f} ║
║ Win Rate: {result.win_rate:>10.1f}% │ Expectancy: ${result.expectancy:>10.2f} ║
║ Avg Win: ${result.avg_win:>10.2f} │ Max Consec. Losses: {result.max_consecutive_losses:>5} ║
║ Avg Loss: ${result.avg_loss:>10.2f} │ Avg Duration: {result.avg_trade_duration:>10} ║
╠══════════════════════════════════════════════════════════════════════╣
║ Capital: ${result.initial_capital:,.0f} → ${result.final_capital:,.0f} ║
║ Parameters: {params_str:<56} ║
╚══════════════════════════════════════════════════════════════════════╝
"""
#!/usr/bin/env python3
"""
Strategy Parameter Optimizer
Grid search and optimization for trading strategy parameters.
Usage:
python optimize.py --strategy sma_crossover --symbol BTC-USD \
--param-grid '{"fast_period": [10,20,30], "slow_period": [50,100,200]}'
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime
from itertools import product
from typing import Dict, Any, List
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent))
from backtest import load_data, run_backtest
from fetch_data import parse_period
def grid_search(
strategy_name: str,
data: pd.DataFrame,
param_grid: Dict[str, List[Any]],
initial_capital: float = 10000,
metric: str = "sharpe_ratio",
) -> pd.DataFrame:
"""Run grid search over parameter combinations."""
# Generate all parameter combinations
param_names = list(param_grid.keys())
param_values = list(param_grid.values())
combinations = list(product(*param_values))
print(f"Testing {len(combinations)} parameter combinations...")
results = []
for i, combo in enumerate(combinations):
params = dict(zip(param_names, combo))
try:
result = run_backtest(
strategy_name=strategy_name,
data=data.copy(),
initial_capital=initial_capital,
params=params,
)
results.append(
{
**params,
"total_return": result.total_return,
"sharpe_ratio": result.sharpe_ratio,
"sortino_ratio": result.sortino_ratio,
"max_drawdown": result.max_drawdown,
"win_rate": result.win_rate,
"profit_factor": result.profit_factor,
"total_trades": result.total_trades,
"calmar_ratio": result.calmar_ratio,
}
)
# Progress indicator
if (i + 1) % 10 == 0:
print(f" Completed {i + 1}/{len(combinations)}")
except Exception as e:
print(f" Error with params {params}: {e}")
continue
df = pd.DataFrame(results)
# Sort by target metric
if metric in df.columns:
df = df.sort_values(metric, ascending=False)
return df
def format_optimization_results(df: pd.DataFrame, param_names: List[str]) -> str:
"""Format optimization results as table."""
output = []
output.append("=" * 80)
output.append("PARAMETER OPTIMIZATION RESULTS")
output.append("=" * 80)
output.append("")
# Top 10 results
output.append("TOP 10 PARAMETER COMBINATIONS (by Sharpe Ratio):")
output.append("-" * 80)
header = param_names + ["Return%", "Sharpe", "MaxDD%", "WinRate%", "Trades"]
output.append(" ".join(f"{h:>10}" for h in header))
output.append("-" * 80)
for _, row in df.head(10).iterrows():
values = [row[p] for p in param_names]
values += [
f"{row['total_return']:.1f}",
f"{row['sharpe_ratio']:.2f}",
f"{row['max_drawdown']:.1f}",
f"{row['win_rate']:.1f}",
f"{row['total_trades']:.0f}",
]
output.append(" ".join(f"{v:>10}" for v in values))
output.append("")
output.append("=" * 80)
# Best parameters
best = df.iloc[0]
output.append("BEST PARAMETERS:")
for p in param_names:
output.append(f" {p}: {best[p]}")
output.append("")
output.append("Expected Performance:")
output.append(f" Total Return: {best['total_return']:.2f}%")
output.append(f" Sharpe Ratio: {best['sharpe_ratio']:.2f}")
output.append(f" Max Drawdown: {best['max_drawdown']:.2f}%")
output.append(f" Win Rate: {best['win_rate']:.1f}%")
output.append("=" * 80)
return "\n".join(output)
def main():
parser = argparse.ArgumentParser(description="Optimize strategy parameters")
parser.add_argument("--strategy", "-s", required=True, help="Strategy name")
parser.add_argument("--symbol", required=True, help="Trading symbol")
parser.add_argument("--param-grid", required=True, help="Parameter grid as JSON")
parser.add_argument("--period", "-p", default="1y", help="Lookback period")
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
parser.add_argument("--capital", "-c", type=float, default=10000, help="Initial capital")
parser.add_argument("--metric", "-m", default="sharpe_ratio", help="Optimization metric")
parser.add_argument("--output", "-o", help="Output directory")
args = parser.parse_args()
# Parse parameter grid
param_grid = json.loads(args.param_grid)
# Determine date range
if args.start and args.end:
start = datetime.strptime(args.start, "%Y-%m-%d")
end = datetime.strptime(args.end, "%Y-%m-%d")
else:
end = datetime.now()
start = end - parse_period(args.period)
# Load data
script_dir = Path(__file__).parent.parent
data_dir = script_dir / "data"
print(f"Loading data for {args.symbol}...")
data = load_data(args.symbol, start, end, data_dir)
data.attrs["symbol"] = args.symbol
print(f"Loaded {len(data)} bars")
# Run optimization
results_df = grid_search(
strategy_name=args.strategy,
data=data,
param_grid=param_grid,
initial_capital=args.capital,
metric=args.metric,
)
# Format and print results
param_names = list(param_grid.keys())
output = format_optimization_results(results_df, param_names)
print(output)
# Save results
output_dir = Path(args.output) if args.output else script_dir / "reports"
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_file = output_dir / f"optimization_{args.strategy}_{timestamp}.csv"
results_df.to_csv(csv_file, index=False)
print(f"\nFull results saved to: {csv_file}")
txt_file = output_dir / f"optimization_{args.strategy}_{timestamp}.txt"
with open(txt_file, "w") as f:
f.write(output)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Trading Strategy Definitions
Each strategy implements generate_signals() returning entry/exit signals.
"""
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any
from dataclasses import dataclass
@dataclass
class Signal:
"""Trading signal with entry/exit information."""
entry: bool = False
exit: bool = False
direction: str = "long" # "long" or "short"
strength: float = 1.0 # Signal strength 0-1
class Strategy(ABC):
"""Base class for all trading strategies."""
name: str = "base"
lookback: int = 1
@abstractmethod
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
"""Generate trading signals from price data."""
pass
def validate_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Validate and set default parameters."""
return params
class SMAcrossover(Strategy):
"""Simple Moving Average Crossover Strategy.
Buy when fast MA crosses above slow MA (golden cross).
Sell when fast MA crosses below slow MA (death cross).
"""
name = "sma_crossover"
lookback = 200
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
params = self.validate_params(params)
fast = params.get("fast_period", 20)
slow = params.get("slow_period", 50)
if len(data) < slow + 1:
return Signal()
close = data["close"]
fast_ma = close.rolling(window=fast).mean()
slow_ma = close.rolling(window=slow).mean()
# Current and previous values
curr_fast, prev_fast = fast_ma.iloc[-1], fast_ma.iloc[-2]
curr_slow, prev_slow = slow_ma.iloc[-1], slow_ma.iloc[-2]
# Golden cross: fast crosses above slow (long only)
if prev_fast <= prev_slow and curr_fast > curr_slow:
return Signal(entry=True, direction="long")
# Death cross: fast crosses below slow (exit long)
if prev_fast >= prev_slow and curr_fast < curr_slow:
return Signal(exit=True)
return Signal()
class EMAcrossover(Strategy):
"""Exponential Moving Average Crossover Strategy."""
name = "ema_crossover"
lookback = 200
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
fast = params.get("fast_period", 12)
slow = params.get("slow_period", 26)
if len(data) < slow + 1:
return Signal()
close = data["close"]
fast_ema = close.ewm(span=fast, adjust=False).mean()
slow_ema = close.ewm(span=slow, adjust=False).mean()
curr_fast, prev_fast = fast_ema.iloc[-1], fast_ema.iloc[-2]
curr_slow, prev_slow = slow_ema.iloc[-1], slow_ema.iloc[-2]
if prev_fast <= prev_slow and curr_fast > curr_slow:
return Signal(entry=True, direction="long")
if prev_fast >= prev_slow and curr_fast < curr_slow:
return Signal(exit=True)
return Signal()
class RSIreversal(Strategy):
"""RSI Overbought/Oversold Reversal Strategy.
Long when RSI crosses above oversold. Short when RSI crosses below overbought.
"""
name = "rsi_reversal"
lookback = 14
def _calculate_rsi(self, close: pd.Series, period: int) -> pd.Series:
delta = close.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
period = params.get("period", 14)
overbought = params.get("overbought", 70)
oversold = params.get("oversold", 30)
if len(data) < period + 1:
return Signal()
rsi = self._calculate_rsi(data["close"], period)
curr_rsi, prev_rsi = rsi.iloc[-1], rsi.iloc[-2]
# Oversold reversal: enter long (also exits any short)
if prev_rsi <= oversold and curr_rsi > oversold:
return Signal(entry=True, exit=True, direction="long", strength=min(1.0, (oversold - prev_rsi) / 10))
# Overbought reversal: enter short (also exits any long)
if prev_rsi >= overbought and curr_rsi < overbought:
return Signal(entry=True, exit=True, direction="short", strength=min(1.0, (prev_rsi - overbought) / 10))
return Signal()
class MACD(Strategy):
"""MACD Signal Line Crossover Strategy (long and short)."""
name = "macd"
lookback = 35
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
fast = params.get("fast", 12)
slow = params.get("slow", 26)
signal_period = params.get("signal", 9)
if len(data) < slow + signal_period:
return Signal()
close = data["close"]
fast_ema = close.ewm(span=fast, adjust=False).mean()
slow_ema = close.ewm(span=slow, adjust=False).mean()
macd_line = fast_ema - slow_ema
signal_line = macd_line.ewm(span=signal_period, adjust=False).mean()
curr_macd, prev_macd = macd_line.iloc[-1], macd_line.iloc[-2]
curr_signal, prev_signal = signal_line.iloc[-1], signal_line.iloc[-2]
# Bullish crossover: enter long, exit short
if prev_macd <= prev_signal and curr_macd > curr_signal:
return Signal(entry=True, exit=True, direction="long")
# Bearish crossover: enter short, exit long
if prev_macd >= prev_signal and curr_macd < curr_signal:
return Signal(entry=True, exit=True, direction="short")
return Signal()
class BollingerBands(Strategy):
"""Bollinger Bands Mean Reversion Strategy (long and short).
Long when price touches lower band. Short when price crosses upper band.
Exit at middle band.
"""
name = "bollinger_bands"
lookback = 20
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
period = params.get("period", 20)
std_dev = params.get("std_dev", 2.0)
if len(data) < period:
return Signal()
close = data["close"]
sma = close.rolling(window=period).mean()
std = close.rolling(window=period).std()
upper_band = sma + (std * std_dev)
lower_band = sma - (std * std_dev)
curr_close = close.iloc[-1]
prev_close = close.iloc[-2]
# Price crosses below lower band: enter long, exit short
if prev_close >= lower_band.iloc[-2] and curr_close < lower_band.iloc[-1]:
return Signal(entry=True, exit=True, direction="long")
# Price crosses above upper band: enter short, exit long
if prev_close <= upper_band.iloc[-2] and curr_close > upper_band.iloc[-1]:
return Signal(entry=True, exit=True, direction="short")
# Price crosses middle band: exit any position
curr_mid = sma.iloc[-1]
prev_mid = sma.iloc[-2]
if (prev_close < prev_mid and curr_close >= curr_mid) or (prev_close > prev_mid and curr_close <= curr_mid):
return Signal(exit=True)
return Signal()
class Breakout(Strategy):
"""Price Breakout Strategy.
Buy when price breaks above recent high.
Sell when price breaks below recent low.
"""
name = "breakout"
lookback = 20
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
lookback = params.get("lookback", 20)
threshold = params.get("threshold", 0.0) # % above/below
if len(data) < lookback + 1:
return Signal()
high = data["high"].iloc[-lookback - 1 : -1]
low = data["low"].iloc[-lookback - 1 : -1]
curr_close = data["close"].iloc[-1]
resistance = high.max() * (1 + threshold / 100)
support = low.min() * (1 - threshold / 100)
# Breakout above resistance
if curr_close > resistance:
return Signal(entry=True, direction="long")
# Breakdown below support
if curr_close < support:
return Signal(exit=True)
return Signal()
class MeanReversion(Strategy):
"""Mean Reversion Strategy (long and short).
Long when price deviates below mean. Short when price deviates above mean.
Exit when z-score crosses zero.
"""
name = "mean_reversion"
lookback = 20
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
period = params.get("period", 20)
z_threshold = params.get("z_threshold", 2.0)
if len(data) < period:
return Signal()
close = data["close"]
sma = close.rolling(window=period).mean()
std = close.rolling(window=period).std()
if std.iloc[-1] == 0 or std.iloc[-2] == 0:
return Signal()
z_score = (close.iloc[-1] - sma.iloc[-1]) / std.iloc[-1]
prev_z_score = (close.iloc[-2] - sma.iloc[-2]) / std.iloc[-2]
# Price significantly below mean: enter long, exit short
if z_score < -z_threshold and prev_z_score >= -z_threshold:
return Signal(entry=True, exit=True, direction="long", strength=min(1.0, abs(z_score) / 3))
# Price significantly above mean: enter short, exit long
if z_score > z_threshold and prev_z_score <= z_threshold:
return Signal(entry=True, exit=True, direction="short", strength=min(1.0, abs(z_score) / 3))
# Price reverts to mean: exit any position
if (prev_z_score < 0 and z_score >= 0) or (prev_z_score > 0 and z_score <= 0):
return Signal(exit=True)
return Signal()
class Momentum(Strategy):
"""Rate of Change Momentum Strategy."""
name = "momentum"
lookback = 14
def generate_signals(self, data: pd.DataFrame, params: Dict[str, Any]) -> Signal:
period = params.get("period", 14)
threshold = params.get("threshold", 5.0) # % change threshold
if len(data) < period + 1:
return Signal()
close = data["close"]
roc = ((close.iloc[-1] - close.iloc[-period]) / close.iloc[-period]) * 100
prev_roc = ((close.iloc[-2] - close.iloc[-period - 1]) / close.iloc[-period - 1]) * 100
# Momentum turns positive and exceeds threshold
if prev_roc <= threshold and roc > threshold:
return Signal(entry=True, direction="long")
# Momentum turns negative
if prev_roc >= 0 and roc < 0:
return Signal(exit=True)
return Signal()
# Strategy registry
STRATEGIES = {
"sma_crossover": SMAcrossover(),
"ema_crossover": EMAcrossover(),
"rsi_reversal": RSIreversal(),
"macd": MACD(),
"bollinger_bands": BollingerBands(),
"breakout": Breakout(),
"mean_reversion": MeanReversion(),
"momentum": Momentum(),
}
def get_strategy(name: str) -> Strategy:
"""Get strategy by name."""
if name not in STRATEGIES:
raise ValueError(f"Unknown strategy: {name}. Available: {list(STRATEGIES.keys())}")
return STRATEGIES[name]
def list_strategies() -> Dict[str, str]:
"""List all available strategies with descriptions."""
return {name: strategy.__doc__.split("\n")[0] for name, strategy in STRATEGIES.items()}
#!/usr/bin/env python3
"""Tests for the backtesting framework."""
import sys
from pathlib import Path
from datetime import timedelta
import numpy as np
import pandas as pd
import pytest
# Add scripts to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from fetch_data import parse_period
from strategies import Signal, get_strategy, list_strategies
from metrics import Trade, calculate_trade_stats
from backtest import run_backtest, load_settings
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_price_data(prices, start="2024-01-01"):
"""Build a minimal DataFrame from a list of close prices."""
dates = pd.date_range(start, periods=len(prices), freq="D")
df = pd.DataFrame(
{
"open": prices,
"high": [p * 1.01 for p in prices],
"low": [p * 0.99 for p in prices],
"close": prices,
"volume": [1000] * len(prices),
},
index=dates,
)
df.index.name = "date"
df.attrs["symbol"] = "TEST"
return df
def make_trending_data(n=300, start_price=100, trend=0.001):
"""Generate upward-trending price series with noise."""
np.random.seed(42)
returns = np.random.normal(trend, 0.02, n)
prices = [start_price]
for r in returns:
prices.append(prices[-1] * (1 + r))
return make_price_data(prices)
def make_mean_reverting_data(n=300, mean=100, std=5):
"""Generate mean-reverting price series."""
np.random.seed(42)
prices = []
price = mean
for _ in range(n):
price = price + 0.3 * (mean - price) + np.random.normal(0, std * 0.3)
prices.append(max(price, 1))
return make_price_data(prices)
# ---------------------------------------------------------------------------
# parse_period
# ---------------------------------------------------------------------------
class TestParsePeriod:
def test_years(self):
assert parse_period("1y") == timedelta(days=365)
assert parse_period("2y") == timedelta(days=730)
def test_months(self):
assert parse_period("6m") == timedelta(days=180)
assert parse_period("1m") == timedelta(days=30)
def test_days(self):
assert parse_period("30d") == timedelta(days=30)
def test_weeks(self):
assert parse_period("4w") == timedelta(weeks=4)
def test_invalid_unit(self):
with pytest.raises(ValueError, match="Unknown period unit"):
parse_period("5x")
# ---------------------------------------------------------------------------
# Signal dataclass
# ---------------------------------------------------------------------------
class TestSignal:
def test_defaults(self):
s = Signal()
assert not s.entry
assert not s.exit
assert s.direction == "long"
assert s.strength == 1.0
def test_short_signal(self):
s = Signal(entry=True, direction="short")
assert s.entry
assert s.direction == "short"
def test_flip_signal(self):
s = Signal(entry=True, exit=True, direction="short")
assert s.entry and s.exit
# ---------------------------------------------------------------------------
# Strategy signals
# ---------------------------------------------------------------------------
class TestStrategies:
def test_list_strategies(self):
strategies = list_strategies()
assert "sma_crossover" in strategies
assert "rsi_reversal" in strategies
assert len(strategies) == 8
def test_get_strategy_invalid(self):
with pytest.raises(ValueError, match="Unknown strategy"):
get_strategy("nonexistent")
def test_sma_crossover_signal(self):
strategy = get_strategy("sma_crossover")
# With insufficient data, should return empty signal
short_data = make_price_data([100] * 10)
signal = strategy.generate_signals(short_data, {})
assert not signal.entry and not signal.exit
def test_rsi_short_signal(self):
"""RSI should generate short signals on overbought reversal."""
strategy = get_strategy("rsi_reversal")
# Build data where RSI goes high then drops
prices = [100] * 20
# Sharp rally to push RSI above 70
for i in range(10):
prices.append(prices[-1] * 1.03)
# Then drop to push RSI below 70
prices.append(prices[-1] * 0.95)
prices.append(prices[-1] * 0.95)
data = make_price_data(prices)
signal = strategy.generate_signals(data, {"period": 14, "overbought": 70, "oversold": 30})
# After sharp rally then drop, should see a short or exit signal
# The exact signal depends on RSI calculation, but direction should be testable
if signal.entry:
assert signal.direction == "short"
def test_macd_generates_short(self):
"""MACD bearish crossover should produce short signal."""
strategy = get_strategy("macd")
# Build data: uptrend then reversal
prices = list(range(100, 160)) # 60 bars up
prices += list(range(160, 130, -1)) # 30 bars down
data = make_price_data(prices)
signal = strategy.generate_signals(data, {})
# After reversal, MACD should eventually cross bearishly
if signal.entry:
assert signal.direction in ("long", "short")
def test_bollinger_short_above_upper(self):
"""Bollinger should short when price crosses above upper band."""
strategy = get_strategy("bollinger_bands")
# Stable prices then sharp spike
prices = [100.0] * 25
prices.append(130.0) # Spike above upper band
data = make_price_data(prices)
signal = strategy.generate_signals(data, {"period": 20, "std_dev": 2.0})
if signal.entry:
assert signal.direction == "short"
def test_mean_reversion_short_above_threshold(self):
"""MeanReversion should short when z-score > threshold."""
strategy = get_strategy("mean_reversion")
# Stable then sharp up
prices = [100.0] * 25
prices.append(120.0) # Big z-score
data = make_price_data(prices)
signal = strategy.generate_signals(data, {"period": 20, "z_threshold": 2.0})
if signal.entry:
assert signal.direction == "short"
# ---------------------------------------------------------------------------
# Trade and metrics
# ---------------------------------------------------------------------------
class TestMetrics:
def test_long_trade_pnl(self):
t = Trade(
entry_time=pd.Timestamp("2024-01-01"),
exit_time=pd.Timestamp("2024-02-01"),
entry_price=100.0,
exit_price=110.0,
direction="long",
size=10.0,
)
assert t.pnl == pytest.approx(100.0)
assert t.pnl_pct == pytest.approx(10.0)
def test_short_trade_pnl(self):
t = Trade(
entry_time=pd.Timestamp("2024-01-01"),
exit_time=pd.Timestamp("2024-02-01"),
entry_price=100.0,
exit_price=90.0,
direction="short",
size=10.0,
)
assert t.pnl == pytest.approx(100.0) # profit from price drop
assert t.pnl_pct == pytest.approx(10.0)
def test_short_trade_loss(self):
t = Trade(
entry_time=pd.Timestamp("2024-01-01"),
exit_time=pd.Timestamp("2024-02-01"),
entry_price=100.0,
exit_price=110.0,
direction="short",
size=10.0,
)
assert t.pnl == pytest.approx(-100.0) # loss from price rise
assert t.pnl_pct == pytest.approx(-10.0)
def test_trade_duration(self):
t = Trade(
entry_time=pd.Timestamp("2024-01-01"),
exit_time=pd.Timestamp("2024-01-31"),
entry_price=100,
exit_price=100,
direction="long",
size=1,
)
assert t.duration == timedelta(days=30)
def test_trade_stats_empty(self):
stats = calculate_trade_stats([])
assert stats["total_trades"] == 0
assert stats["win_rate"] == 0.0
def test_trade_stats_mixed(self):
trades = [
Trade(pd.Timestamp("2024-01-01"), pd.Timestamp("2024-01-10"), 100, 120, "long", 1),
Trade(pd.Timestamp("2024-01-11"), pd.Timestamp("2024-01-20"), 120, 110, "long", 1),
]
stats = calculate_trade_stats(trades)
assert stats["total_trades"] == 2
assert stats["win_rate"] == 50.0
# ---------------------------------------------------------------------------
# Settings loading
# ---------------------------------------------------------------------------
class TestSettings:
def test_load_settings_existing(self):
skill_dir = Path(__file__).parent.parent
settings = load_settings(skill_dir)
# If yaml is available and settings.yaml exists, should have backtest key
if settings:
assert "backtest" in settings or "risk" in settings
def test_load_settings_missing_dir(self, tmp_path):
settings = load_settings(tmp_path)
assert settings == {}
# ---------------------------------------------------------------------------
# Stop-loss / Take-profit enforcement
# ---------------------------------------------------------------------------
class TestRiskManagement:
def test_stop_loss_triggers(self):
"""A stop loss should limit downside."""
# Build data: uptrend then sharp drop then recovery (enough for momentum lookback=14)
prices = [100.0 + i * 0.5 for i in range(30)] # uptrend
for i in range(30):
prices.append(prices[-1] * 0.98) # steady decline
for i in range(30):
prices.append(prices[-1] * 1.005) # slow recovery
data = make_price_data(prices)
# Without stop loss
result_no_sl = run_backtest("momentum", data, params={"period": 14, "threshold": 3.0})
# With tight stop loss
result_sl = run_backtest(
"momentum",
data,
params={"period": 14, "threshold": 3.0},
risk_settings={"stop_loss": 0.03},
)
# Stop loss should cause more trades (early exits)
assert (
result_sl.total_trades >= result_no_sl.total_trades or result_sl.final_capital >= result_no_sl.final_capital
)
def test_take_profit_triggers(self):
"""A take profit should lock in gains."""
data = make_trending_data(n=300, trend=0.003)
result_tp = run_backtest(
"sma_crossover",
data,
params={"fast_period": 5, "slow_period": 20},
risk_settings={"take_profit": 0.05},
)
# With take profit, should have more trades (exiting early)
result_no_tp = run_backtest(
"sma_crossover",
data,
params={"fast_period": 5, "slow_period": 20},
)
assert result_tp.total_trades >= result_no_tp.total_trades
def test_max_position_size(self):
"""Custom max_position_size should be respected."""
data = make_trending_data(n=300)
result = run_backtest(
"sma_crossover",
data,
params={"fast_period": 5, "slow_period": 20},
risk_settings={"max_position_size": 0.5},
)
# Should still produce valid results
assert result.final_capital > 0
assert result.initial_capital == 10000
# ---------------------------------------------------------------------------
# Full backtest flow
# ---------------------------------------------------------------------------
class TestBacktestFlow:
def test_basic_backtest_runs(self):
"""A basic backtest should complete without errors."""
data = make_trending_data(n=300)
result = run_backtest("sma_crossover", data, params={"fast_period": 10, "slow_period": 30})
assert result.strategy == "sma_crossover"
assert result.initial_capital == 10000
assert result.final_capital > 0
assert len(result.equity_curve) > 0
def test_all_strategies_run(self):
"""Every registered strategy should produce a valid result."""
data = make_trending_data(n=300)
for name in list_strategies():
result = run_backtest(name, data.copy())
assert result.final_capital > 0, f"{name} produced zero capital"
assert isinstance(result.total_return, float), f"{name} has bad total_return"
def test_short_strategy_backtest(self):
"""Strategies with short signals should record short trades."""
# Mean-reverting data should trigger mean_reversion shorts
data = make_mean_reverting_data(n=300)
result = run_backtest("mean_reversion", data, params={"period": 20, "z_threshold": 1.5})
# May or may not have shorts depending on data, but should not error
assert result.final_capital > 0
def test_equity_curve_length(self):
"""Equity curve should match data length minus lookback + 1."""
data = make_trending_data(n=300)
strategy = get_strategy("sma_crossover")
result = run_backtest("sma_crossover", data, params={"fast_period": 10, "slow_period": 30})
expected_len = len(data) - strategy.lookback + 1
assert len(result.equity_curve) == expected_len
def test_metrics_calculated(self):
"""All metric fields should be populated after backtest."""
data = make_trending_data(n=300)
result = run_backtest("sma_crossover", data, params={"fast_period": 10, "slow_period": 30})
assert isinstance(result.sharpe_ratio, float)
assert isinstance(result.max_drawdown, float)
assert isinstance(result.total_trades, int)
Related skills
Forks & variants (2)
Backtesting Trading Strategies has 2 known copies in the catalog totaling 1.9k installs. They canonicalize to this original listing.
- gracefullight - 1.9k installs
- kirkluokun - 46 installs
How it compares
backtesting-trading-strategies is an agent skill for backtest crypto and traditional trading strategies with sharpe, drawdown metrics, and parameter grid search, not a generic alternative.
FAQ
Who is backtesting-trading-strategies for?
Developers using Finance & Trading workflows with agent-guided SKILL.md steps.
When should I use backtesting-trading-strategies?
Backtest crypto and traditional trading strategies with Sharpe, drawdown metrics, and parameter grid search.
Is backtesting-trading-strategies safe to install?
Review the Security Audits panel on this page before installing in production.