
Crypto Backtest
- 102 installs
- 18 repo stars
- Updated January 25, 2026
- 0xrikt/crypto-skills
crypto-backtest is a Claude Code skill that turns natural-language crypto trading ideas into backtested spot-only strategies with data from CCXT and interactive HTML reports.
About
crypto-backtest is a Claude Code skill that converts natural-language crypto trading ideas into validated spot-only strategies with professional backtesting. It fetches historical data through CCXT, computes technical indicators with pandas-ta, simulates a portfolio with stop-loss and take-profit, and outputs an interactive HTML report plus runnable Python. A trader or developer uses it to validate a strategy idea before risking capital. It supports spot long-only strategies with no leverage or shorting.
- Turns natural-language crypto trading ideas into backtested, spot-only strategies with runnable Python
- Fetches real historical data via CCXT across 200+ exchanges and generates interactive Plotly HTML reports
- Expands vague signals into multi-factor models across momentum, trend, volatility, volume, and price-action indicators
Crypto Backtest by the numbers
- 102 all-time installs (skills.sh)
- Ranked #521 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
crypto-backtest capabilities & compatibility
Free; uses public exchange market data via CCXT, no API key required
- Capabilities
- backtesting · trading strategy · technical analysis · report generation
- Works with
- tradingview
- Use cases
- trading · data analysis · research
- Pricing
- Free
What crypto-backtest says it does
Transform natural language trading ideas into validated strategies with professional backtesting, beautiful reports, and runnable code.
npx skills add https://github.com/0xrikt/crypto-skills --skill crypto-backtestAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 102 |
|---|---|
| repo stars | ★ 18 |
| Last updated | January 25, 2026 |
| Repository | 0xrikt/crypto-skills ↗ |
What it does
Backtest a crypto trading idea into a validated spot strategy with real historical data and a report.
Who is it for?
traders validating spot crypto strategy ideas against historical data
Skip if: leverage, shorting, or futures/perpetual strategies; the skill is spot long-only
When should I use this skill?
a user describes a trading idea or wants to backtest or validate a crypto strategy (RSI, MACD, DCA, oversold, etc.)
What you get
A backtested, multi-factor spot strategy with an interactive report and runnable Python code.
By the numbers
- 200+ exchanges via CCXT
- supports 20+ technical indicators across momentum, trend, volatility, volume, and price action
Files
Crypto Strategy Backtest Skill
Transform natural language trading ideas into validated strategies with professional backtesting, beautiful reports, and runnable code.
⚠️ SPOT ONLY: This skill supports spot trading strategies only. No leverage, no shorting, no futures/perpetual contracts. All strategies are long-only (buy low → hold → sell high).
Your Superpower
You turn vague trading intuitions into professional-grade, multi-dimensional strategies. When users say "buy when cheap", you don't just slap on RSI < 30 — you build a comprehensive valuation model using multiple indicators, each with proper reasoning.
Your goal: Make strategy completion so thorough that users think "wow, I wouldn't have thought of all this myself."
---
CRITICAL: Parameter Estimation Principles
Think like a quant: Every parameter must be justified, not guessed.
1. Threshold = f(historical volatility): For spreads, use 1.5-2× standard deviation. For RSI, use 30/70 (BTC) or 25/75 (alts). 2. Stop Loss > normal noise: BTC daily vol is 3-5%, so stop loss should be 5-8%, not 2%. 3. Respect user's exact specs: If user says "every 6 hours", use 6h, not 2h. 4. Spot only: leverage = 1x, always. No perpetuals, no shorting. 5. Position Size is PERCENTAGE: --position-size 10 means 10% of capital per trade, NOT $10! 6. OKX data limit: OKX only provides ~60-90 days of history. For longer backtests, use --exchange kucoin or --exchange binance (if accessible).
---
CRITICAL: Strategy Completion Standards
When translating natural language to technical conditions, NEVER use single indicators. Always combine multiple dimensions:
🎯 "Undervalued/Cheap/Oversold/Dip" → Multi-Factor Valuation Model
DON'T: RSI(14) < 30 (too simplistic, easily fooled by trends)
DO: Combine 4-5 indicators for robust valuation scoring:
| Dimension | Indicator | Bullish Signal | Weight |
|---|---|---|---|
| Momentum | RSI(14) | < 35 | 1.0 |
| Trend Position | Price vs SMA(200) | Price < SMA200 | 1.0 |
| Volatility Band | Bollinger Bands | Price < BB_Lower | 1.0 |
| Drawdown | Price vs 90-day High | Drawdown > 25% | 1.0 |
| Momentum Divergence | MACD Histogram | Turning positive while price low | 0.5 |
| Volume Confirmation | Volume vs MA(20) | Volume spike (>1.5x) on dip | 0.5 |
Valuation Score = Sum of triggered signals × weights
- Score ≥ 3.0: Strong undervaluation
- Score 2.0-3.0: Moderate undervaluation
- Score < 2.0: Weak/no signal
📈 "Overvalued/Expensive/Overbought" → Multi-Factor Model
| Dimension | Indicator | Bearish Signal | Weight |
|---|---|---|---|
| Momentum | RSI(14) | > 70 | 1.0 |
| Trend Extension | Price vs SMA(200) | Price > SMA200 × 1.3 | 1.0 |
| Volatility Band | Bollinger Bands | Price > BB_Upper | 1.0 |
| From Recent Low | Price vs 90-day Low | Gain > 50% | 1.0 |
| Momentum Divergence | MACD Histogram | Turning negative while price high | 0.5 |
| Volume Dry-up | Volume vs MA(20) | Volume declining on rally | 0.5 |
🚀 "Trend/Bullish/Uptrend" → Multi-Timeframe Confirmation
DON'T: Price > EMA(21) (single timeframe, easily whipsawed)
DO: Require alignment across timeframes:
| Timeframe | Condition | Purpose |
|---|---|---|
| Long-term | Price > SMA(200) | Major trend direction |
| Medium-term | Price > EMA(50) | Intermediate trend |
| Short-term | EMA(9) > EMA(21) | Recent momentum |
| Momentum | MACD > Signal Line | Acceleration |
| Strength | ADX > 25 | Trend strength confirmation |
Entry: All conditions aligned Exit: Short-term reversal (EMA9 < EMA21) OR momentum loss (MACD cross down)
💥 "Breakout" → Volume-Confirmed Breakout
DON'T: Price > BB_Upper (many false breakouts)
DO: Require multiple confirmations:
| Condition | Purpose |
|---|---|
| Price > BB_Upper(20, 2.0) | Statistical breakout |
| Volume > 2.0 × Volume_MA(20) | Strong participation |
| Close in top 25% of candle range | Buying pressure |
| RSI(14) > 50 but < 80 | Momentum without exhaustion |
| Previous 5 candles: tight range (BB width contracting) | Coiled energy |
📊 "DCA" → Smart DCA with Valuation Adjustment
DON'T: Fixed amount every period (misses opportunities)
DO: Dynamic allocation based on valuation score:
| Valuation Score | Market State | Allocation |
|---|---|---|
| ≥ +3.0 | 🟢🟢 Extreme undervaluation | Base × 2.0 |
| +1.5 to +3.0 | 🟢 Undervalued | Base × 1.5 |
| -1.5 to +1.5 | 🟡 Fair value | Base × 1.0 |
| -3.0 to -1.5 | 🔴 Overvalued | Base × 0.5 |
| ≤ -3.0 | 🔴🔴 Extreme overvaluation | Base × 0.25 |
🔄 "Mean Reversion" → Statistical Deviation Strategy
| Condition | Entry | Exit |
|---|---|---|
| Z-Score | Price Z-score < -2.0 | Z-score > 0 |
| BB Position | Price < BB_Lower | Price > BB_Middle |
| RSI | RSI < 30 | RSI > 50 |
| Confirmation | Volume spike on dip | - |
---
Workflow
Step 1: Understand & Expand the Intent
When user describes a trading idea:
1. Identify the core strategy type: Mean reversion? Trend following? Breakout? DCA? 2. Extract constraints: Asset, timeframe, risk tolerance 3. Expand to multi-dimensional conditions using the templates above 4. Add appropriate risk management based on strategy type
Step 2: Present Complete Strategy for Confirmation
CRITICAL: Present strategy in this exact YAML format. Users should be impressed by the thoroughness.
## 📊 Strategy: [Descriptive Name]
# Core Logic: [One sentence explaining the edge]
Data:
primary_symbol: BTC/USDT
timeframe: 6h # MUST match user's specification
backtest_period: 365 days
indicators:
RSI: { period: 14 }
SMA: { period: 50, 200 }
BB: { period: 20, std_dev: 2 }
data_requirements: [close_price, volume]
Signal:
entry_conditions:
condition_type: ALL # ALL conditions must be met
conditions:
- RSI < 35
- Price < BB_lower
- Price < SMA200 * 0.98
exit_conditions:
condition_type: ANY # ANY condition triggers exit
conditions:
- RSI > 70
- Price > BB_upper
- Price > SMA200 * 1.05
execution_schedule:
frequency: 6h # MUST match user's specification
check_times: [00:00, 06:00, 12:00, 18:00]
Capital:
total_capital: 10000
position_size_pct: 10 # 10% of capital per trade (NOT fixed dollar amount!)
reserve_ratio: 0.2 # 20% kept as cash buffer
max_drawdown_limit: 0.15 # 15% max drawdown
Risk:
stop_loss: 8%
take_profit: 15% # or null if exit by signal only
max_account_risk: 0.75
emergency_rules:
account_risk_threshold: 0.8 # If 80% at risk → close_all
Execution:
leverage: 1x # SPOT ONLY - always 1x
order_type: market
position_side: long_only
max_positions: 1✅ Please review and confirm. Reply "OK" to run backtest, or tell me what to adjust.
⛔ STOP: WAIT FOR USER CONFIRMATION
DO NOT proceed to Step 3 until user explicitly confirms the strategy.
- If user says "OK", "确认", "没问题", "go ahead" → proceed to backtest
- If user has questions or wants changes → modify strategy and present again
- NEVER run backtest without user confirmation
---
Step 3: Run Backtest (ONLY after user confirms)
IMPORTANT: Detect the user's language and pass the --lang parameter accordingly:
- If user writes in Chinese →
--lang zh - If user writes in English →
--lang en
This ensures the HTML report text matches the user's language.
python src/backtest.py \
--symbol "BTC/USDT" \
--timeframe "4h" \
--days 365 \
--entry "rsi<35,price<sma200,price<bb_lower" \
--exit "rsi>50,price>bb_middle" \
--stop-loss 8 \
--take-profit 20 \
--output report.html \
--lang zh # or --lang en based on user's languageStep 4: Present Results with Insights
Show metrics AND provide actionable insights:
## 📈 Backtest Results
| Metric | Value | Assessment |
|--------|-------|------------|
| Total Return | +47.3% | ✅ Beats B&H |
| Max Drawdown | -18.2% | ⚠️ Moderate |
| Sharpe Ratio | 1.42 | ✅ Good |
| Win Rate | 64% | ✅ Healthy |
| Profit Factor | 2.1 | ✅ Strong |
### Key Insights:
- Strategy performed best during [market condition]
- Largest drawdown occurred during [event]
- Consider [specific improvement] to reduce drawdown
### Generated Files:
- `report.html` - Interactive visual report
- `strategy.py` - Runnable Python codeStep 5: Suggest Iterations
Based on results, proactively suggest:
- Parameter optimizations
- Additional filters
- Alternative approaches
- Risk adjustments
---
Strategy Templates Reference
Template 1: Multi-Factor Value Buying
DATA:
- Symbol: BTC/USDT | Timeframe: 4h | Period: 365d
- Indicators: RSI(14), SMA(200), BB(20,2), High_90
SIGNAL:
| Entry (ALL) | Exit (ANY) |
|---|---|
| RSI < 35 | RSI > 65 |
| Price < SMA200_98pct | Price > SMA200 |
| Price < BB_lower | Price > BB_middle |
| Drawdown > 25% | Stop Loss 10% |
RISK: Stop 10% | Take Profit 25% | Position 10%
---
Template 2: Trend Following with Confirmation
DATA:
- Symbol: BTC/USDT | Timeframe: 4h | Period: 365d
- Indicators: SMA(200), EMA(9,21,50), MACD, ADX
SIGNAL:
| Entry (ALL) | Exit (ANY) |
|---|---|
| Price > SMA200 | EMA9 < EMA21 |
| EMA9 > EMA21 | Price < SMA50 |
| MACD > MACD_signal | MACD crossunder |
| ADX > 25 | Stop Loss 8% |
RISK: Stop 8% | Trailing Stop 3xATR | Position 15%
---
Template 3: Volume-Confirmed Breakout
DATA:
- Symbol: BTC/USDT | Timeframe: 1h | Period: 180d
- Indicators: High_20, Volume_MA(20), RSI(14), BB(20,2)
SIGNAL:
| Entry (ALL) | Exit (ANY) |
|---|---|
| Price > High_20 | Price < EMA21 |
| Volume > Volume_MA_200pct | RSI > 80 |
| RSI between 50-75 | Stop Loss 5% |
| BB_width contracting | Take Profit 15% |
RISK: Stop 5% | Take Profit 15% | Position 20%
---
Template 4: Smart DCA
DATA:
- Symbol: BTC/USDT | Timeframe: 1d | Frequency: Weekly
SIGNAL (Valuation-Based Allocation):
| Score | Market State | Allocation |
|---|---|---|
| ≥ +3 | 🟢🟢 Strong buy zone | Base × 2.0 |
| +1.5 to +3 | 🟢 Undervalued | Base × 1.5 |
| -1.5 to +1.5 | 🟡 Fair value | Base × 1.0 |
| -3 to -1.5 | 🔴 Overvalued | Base × 0.5 |
| ≤ -3 | 🔴🔴 Extreme caution | Base × 0.25 |
CAPITAL: Base $200/week | Reserve 20% | Max DD 15%
---
Template 5: Pair Trading / Relative Strength
CONCEPT: When two correlated assets (e.g., BTC & ETH) diverge significantly, the underperformer tends to catch up. Trade this mean reversion.
Data:
primary_symbols: [BTC/USDT, ETH/USDT]
timeframe: 4h
lookback_period: 20 # For calculating relative performance
data_requirements: [close_price]
Signal:
entry_conditions:
condition_type: ANY
conditions:
- spread > +5% # BTC outperforming → Long ETH
- spread < -5% # ETH outperforming → Long BTC
exit_conditions:
condition_type: ANY
conditions:
- abs(spread) < 1% # Spread reverted to mean
- stop_loss: -8%
- take_profit: +15%
Capital:
total_capital: 10000
allocation_per_trade: 20% # of total capital
reserve_ratio: 0.3
Risk:
stop_loss: 8%
take_profit: 15%
max_drawdown_limit: 15%
Execution:
leverage: 1x (spot only)
order_type: market
position_side: long_onlyIMPORTANT THRESHOLD GUIDELINES:
- 20-period (80h ≈ 3.3 days) spread: use 3-5% threshold
- 50-period (200h ≈ 8 days) spread: use 5-8% threshold
- Never use >10% for short lookback - signals will never trigger!
---
Technical Reference
Indicators Available (pandas-ta)
Momentum: RSI, MACD, Stochastic, Williams %R, CCI
Trend: SMA, EMA, ADX, Aroon, Supertrend
Volatility: Bollinger Bands, ATR, Keltner Channels
Volume: OBV, Volume SMA, VWAPRisk Profiles
Conservative: SL=5%, TP=12%, position=5%, max 3 concurrent
Moderate: SL=8%, TP=20%, position=10%, max 5 concurrent
Aggressive: SL=12%, TP=35%, position=20%, max 8 concurrent---
Technical Reference: Available Indicators
Momentum Indicators
| Indicator | Column Name | Description |
|---|---|---|
| RSI | rsi | Relative Strength Index (14) |
| Stochastic %K | stoch_k | Stochastic oscillator K line |
| Stochastic %D | stoch_d | Stochastic oscillator D line |
| Williams %R | willr | Williams %R (14) |
| CCI | cci | Commodity Channel Index (20) |
| MFI | mfi | Money Flow Index (14) |
| ROC | roc, roc_20 | Rate of Change (10, 20) |
| MACD | macd, macd_signal, macd_hist | MACD line, signal, histogram |
Trend Indicators
| Indicator | Column Name | Description |
|---|---|---|
| SMA | sma9, sma21, sma50, sma100, sma200 | Simple Moving Averages |
| EMA | ema9, ema21, ema50, ema100, ema200 | Exponential Moving Averages |
| ADX | adx | Average Directional Index (trend strength) |
| +DI / -DI | plus_di, minus_di | Directional Indicators |
Volatility Indicators
| Indicator | Column Name | Description |
|---|---|---|
| Bollinger Bands | bb_upper, bb_middle, bb_lower | Upper, middle, lower bands |
| BB Width | bb_width | Band width (volatility measure) |
| BB %B | bb_pct | Price position in BB range (0-1) |
| ATR | atr | Average True Range (14) |
| ATR % | atr_pct | ATR as % of price |
Volume Indicators
| Indicator | Column Name | Description |
|---|---|---|
| Volume SMA | volume_sma | 20-period volume average |
| Volume Ratio | volume_ratio | Current volume / average |
| OBV | obv, obv_sma | On-Balance Volume |
Price Position Indicators
| Indicator | Column Name | Description |
|---|---|---|
| Rolling High | high_20, high_50, high_90, high_200 | N-period high |
| Rolling Low | low_20, low_50, low_90, low_200 | N-period low |
| Drawdown | drawdown, drawdown_50 | % from rolling high |
| Price Position | price_position_90 | Position in 90-day range (0-1) |
| Distance from MA | dist_sma50, dist_sma200 | % distance from MA |
Derived / Change Indicators
| Indicator | Column Name | Description |
|---|---|---|
| Price Change | price_change, price_pct_change | 1-period change |
| Price Change 5 | price_change_5, price_pct_change_5 | 5-period change |
| RSI Change | rsi_change | RSI momentum |
| MACD Change | macd_change, macd_hist_change | MACD momentum |
| Consecutive Up | consecutive_up | Count of consecutive up days |
| Consecutive Down | consecutive_down | Count of consecutive down days |
---
Technical Reference: Condition Syntax
1. Simple Comparisons
rsi<30 # RSI below 30
price>sma200 # Price above SMA 200
adx>=25 # ADX at least 25
bb_pct<0.2 # Price in lower 20% of BB range
drawdown<-20 # Down 20% from recent high
volume_ratio>2 # Volume 2x above average2. Crossover / Crossunder
macd_crossover # MACD crosses above signal (default)
ema9_cross_above_ema21 # EMA9 crosses above EMA21
price_crossover_sma50 # Price crosses above SMA50
rsi_crossunder_50 # RSI crosses below 50
stoch_k_cross_above_stoch_d # Stochastic golden cross3. Turning Points
rsi_turning_up # RSI starts increasing
macd_hist_turning_down # MACD histogram starts decreasing
price_turning_up # Price reversal upward4. Consecutive Periods
rsi<30_for_3 # RSI below 30 for 3 consecutive periods
price>sma200_for_5 # Price above SMA200 for 5 periods
consecutive_up>=3 # At least 3 consecutive up days5. Combined Conditions
Conditions are comma-separated. Entry uses AND logic, Exit uses OR logic.
--entry "rsi<35,price<bb_lower,volume_ratio>1.5"
--exit "rsi>70,price>bb_upper"---
Important Guidelines
1. Never use single indicators - Always combine multiple dimensions 2. Explain the logic - Users should understand WHY each indicator is included 3. Match complexity to strategy - DCA needs valuation model, trend following needs multi-TF 4. Be honest about limitations - Past performance ≠ future results 5. Encourage iteration - Backtesting is a process, not a one-shot
---
⚠️ CRITICAL: Exchange Data Limits
Different exchanges have different historical data limits!
| Exchange | Approximate Limit | Notes |
|---|---|---|
| OKX | ~60-90 days | Default. Good for short-term backtests |
| KuCoin | ~200 days | Good alternative for medium-term |
| Binance | 1000+ days | Most data, but blocked in some regions |
| Bybit | ~200 days | Good alternative |
If you need more than 90 days of data:
# OKX default - will only get ~90 days even if you request 365
python src/backtest.py --symbol BTC/USDT --days 365 ...
# Use KuCoin for ~200 days (works in most regions)
python src/backtest.py --symbol BTC/USDT --days 180 --exchange kucoin ...
# Use Binance for 365+ days (if accessible in your region)
python src/backtest.py --symbol BTC/USDT --days 365 --exchange binance ...---
File Locations
- Backtest engine:
src/backtest.py - Smart DCA:
src/smart_dca.py - Pair Trading:
src/pair_trading.py - Output reports: current working directory
- Generated code: current working directory
Smart DCA Usage
For DCA strategies, use the dedicated Smart DCA script:
python src/smart_dca.py \
--symbol "BTC/USDT" \
--days 1095 \
--base-amount 200 \
--frequency 7 \
--output smart_dca_report.html \
--lang zh # or --lang en based on user's languagePair Trading / Relative Strength Usage
For pair trading strategies that compare two assets (e.g., BTC vs ETH):
python src/pair_trading.py \
--symbol-a "BTC/USDT" \
--symbol-b "ETH/USDT" \
--days 365 \
--timeframe 4h \
--lookback 20 \
--threshold 10 \
--exit-threshold 2 \
--output pair_trading_report.html \
--lang zh # or --lang en based on user's language \
--description "Long the underperformer when BTC/ETH spread deviates"Parameters:
--lookback: Period for calculating relative performance (default: 20)--threshold: Entry threshold - spread deviation % to trigger entry (default: 10)--exit-threshold: Exit threshold - spread deviation % to close position (default: 2)
Language Support
CRITICAL: Always detect the user's language and pass the appropriate --lang parameter:
- User writes in Chinese →
--lang zh(report in Chinese) - User writes in English →
--lang en(report in English)
This ensures the generated HTML report matches the user's language preference.
#!/usr/bin/env python3
"""
Trading Strategy
================
Auto-generated by Crypto Backtest Skill
https://github.com/0xrikt/crypto-skills
Asset: BTC/USDT
Timeframe: 6h
Entry: rsi<35,price<sma200*0.95,price<bb_lower,drawdown<-25,volume_ratio>1.5
Exit: rsi>70,price>sma200*1.15,price>bb_upper
Stop Loss: 8.0%
Take Profit: 15%
"""
import ccxt
import pandas as pd
import pandas_ta as ta
from datetime import datetime, timedelta
# =============================================================================
# CONFIGURATION
# =============================================================================
SYMBOL = "BTC/USDT"
TIMEFRAME = "6h"
EXCHANGE = "binance"
# Risk Management
INITIAL_CAPITAL = 10000
POSITION_SIZE_PCT = 20.0 # % of portfolio per trade
STOP_LOSS_PCT = 8.0
TAKE_PROFIT_PCT = 15
COMMISSION_PCT = 0.1
# =============================================================================
# DATA FETCHING
# =============================================================================
def fetch_data(days: int = 365) -> pd.DataFrame:
"""Fetch historical OHLCV data."""
exchange = getattr(ccxt, EXCHANGE)({'enableRateLimit': True})
since = exchange.parse8601((datetime.utcnow() - timedelta(days=days)).isoformat())
all_ohlcv = []
while True:
ohlcv = exchange.fetch_ohlcv(SYMBOL, TIMEFRAME, since=since, limit=1000)
if not ohlcv:
break
all_ohlcv.extend(ohlcv)
since = ohlcv[-1][0] + 1
if len(ohlcv) < 1000:
break
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
# =============================================================================
# INDICATORS
# =============================================================================
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""Calculate technical indicators."""
df = df.copy()
# RSI
df['rsi'] = ta.rsi(df['close'], length=14)
# MACD
macd = ta.macd(df['close'], fast=12, slow=26, signal=9)
if macd is not None:
df['macd'] = macd.iloc[:, 0]
df['macd_signal'] = macd.iloc[:, 2]
# Moving Averages
df['sma50'] = ta.sma(df['close'], length=50)
df['ema21'] = ta.ema(df['close'], length=21)
# Bollinger Bands
bb = ta.bbands(df['close'], length=20, std=2.0)
if bb is not None:
df['bb_upper'] = bb.iloc[:, 2]
df['bb_lower'] = bb.iloc[:, 0]
return df
# =============================================================================
# SIGNAL GENERATION
# =============================================================================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
"""Generate entry and exit signals."""
df = df.copy()
# Entry conditions: rsi<35,price<sma200*0.95,price<bb_lower,drawdown<-25,volume_ratio>1.5
entry = pd.Series(True, index=df.index)
# TODO: Customize entry conditions
entry &= df['rsi'] < 30 # Example
# Exit conditions: rsi>70,price>sma200*1.15,price>bb_upper
exit_signal = pd.Series(False, index=df.index)
# TODO: Customize exit conditions
exit_signal |= df['rsi'] > 70 # Example
df['entry_signal'] = entry.astype(int)
df['exit_signal'] = exit_signal.astype(int)
return df
# =============================================================================
# BACKTEST
# =============================================================================
def backtest(df: pd.DataFrame) -> dict:
"""Run backtest simulation."""
capital = INITIAL_CAPITAL
position = 0.0
entry_price = 0.0
trades = []
for timestamp, row in df.iterrows():
price = row['close']
# Check stop-loss / take-profit
if position > 0:
pnl_pct = (price - entry_price) / entry_price * 100
if pnl_pct <= -STOP_LOSS_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'stop_loss'})
position = 0
elif pnl_pct >= TAKE_PROFIT_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'take_profit'})
position = 0
# Entry
if row['entry_signal'] == 1 and position == 0:
position_value = capital * POSITION_SIZE_PCT / 100
position = position_value / price
entry_price = price
capital -= position_value * (1 + COMMISSION_PCT / 100)
# Exit
elif row['exit_signal'] == 1 and position > 0:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
pnl_pct = (price - entry_price) / entry_price * 100
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'signal'})
position = 0
# Close remaining
if position > 0:
capital += position * df.iloc[-1]['close']
return {
'final_capital': capital,
'return_pct': (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100,
'total_trades': len(trades),
'trades': trades
}
# =============================================================================
# MAIN
# =============================================================================
if __name__ == "__main__":
print(f"Fetching {SYMBOL} data...")
df = fetch_data(days=365)
print(f"Got {len(df)} candles")
print("Calculating indicators...")
df = calculate_indicators(df)
print("Generating signals...")
df = generate_signals(df)
print("Running backtest...")
results = backtest(df)
print("\n" + "="*50)
print("BACKTEST RESULTS")
print("="*50)
print(f"Final Capital: ${results['final_capital']:,.2f}")
print(f"Return: {results['return_pct']:+.2f}%")
print(f"Total Trades: {results['total_trades']}")
#!/usr/bin/env python3
"""
Trading Strategy
================
Auto-generated by Crypto Backtest Skill
https://github.com/0xrikt/crypto-skills
Asset: BTC/USDT
Timeframe: 6h
Entry: rsi<35,price<sma200*0.95,price<bb_lower,drawdown<-25,volume_ratio>1.5
Exit: rsi>70,price>sma200*1.15,price>bb_upper
Stop Loss: 8.0%
Take Profit: 15%
"""
import ccxt
import pandas as pd
import pandas_ta as ta
from datetime import datetime, timedelta
# =============================================================================
# CONFIGURATION
# =============================================================================
SYMBOL = "BTC/USDT"
TIMEFRAME = "6h"
EXCHANGE = "binance"
# Risk Management
INITIAL_CAPITAL = 10000
POSITION_SIZE_PCT = 1.0 # % of portfolio per trade
STOP_LOSS_PCT = 8.0
TAKE_PROFIT_PCT = 15
COMMISSION_PCT = 0.1
# =============================================================================
# DATA FETCHING
# =============================================================================
def fetch_data(days: int = 365) -> pd.DataFrame:
"""Fetch historical OHLCV data."""
exchange = getattr(ccxt, EXCHANGE)({'enableRateLimit': True})
since = exchange.parse8601((datetime.utcnow() - timedelta(days=days)).isoformat())
all_ohlcv = []
while True:
ohlcv = exchange.fetch_ohlcv(SYMBOL, TIMEFRAME, since=since, limit=1000)
if not ohlcv:
break
all_ohlcv.extend(ohlcv)
since = ohlcv[-1][0] + 1
if len(ohlcv) < 1000:
break
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
# =============================================================================
# INDICATORS
# =============================================================================
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""Calculate technical indicators."""
df = df.copy()
# RSI
df['rsi'] = ta.rsi(df['close'], length=14)
# MACD
macd = ta.macd(df['close'], fast=12, slow=26, signal=9)
if macd is not None:
df['macd'] = macd.iloc[:, 0]
df['macd_signal'] = macd.iloc[:, 2]
# Moving Averages
df['sma50'] = ta.sma(df['close'], length=50)
df['ema21'] = ta.ema(df['close'], length=21)
# Bollinger Bands
bb = ta.bbands(df['close'], length=20, std=2.0)
if bb is not None:
df['bb_upper'] = bb.iloc[:, 2]
df['bb_lower'] = bb.iloc[:, 0]
return df
# =============================================================================
# SIGNAL GENERATION
# =============================================================================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
"""Generate entry and exit signals."""
df = df.copy()
# Entry conditions: rsi<35,price<sma200*0.95,price<bb_lower,drawdown<-25,volume_ratio>1.5
entry = pd.Series(True, index=df.index)
# TODO: Customize entry conditions
entry &= df['rsi'] < 30 # Example
# Exit conditions: rsi>70,price>sma200*1.15,price>bb_upper
exit_signal = pd.Series(False, index=df.index)
# TODO: Customize exit conditions
exit_signal |= df['rsi'] > 70 # Example
df['entry_signal'] = entry.astype(int)
df['exit_signal'] = exit_signal.astype(int)
return df
# =============================================================================
# BACKTEST
# =============================================================================
def backtest(df: pd.DataFrame) -> dict:
"""Run backtest simulation."""
capital = INITIAL_CAPITAL
position = 0.0
entry_price = 0.0
trades = []
for timestamp, row in df.iterrows():
price = row['close']
# Check stop-loss / take-profit
if position > 0:
pnl_pct = (price - entry_price) / entry_price * 100
if pnl_pct <= -STOP_LOSS_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'stop_loss'})
position = 0
elif pnl_pct >= TAKE_PROFIT_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'take_profit'})
position = 0
# Entry
if row['entry_signal'] == 1 and position == 0:
position_value = capital * POSITION_SIZE_PCT / 100
position = position_value / price
entry_price = price
capital -= position_value * (1 + COMMISSION_PCT / 100)
# Exit
elif row['exit_signal'] == 1 and position > 0:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
pnl_pct = (price - entry_price) / entry_price * 100
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'signal'})
position = 0
# Close remaining
if position > 0:
capital += position * df.iloc[-1]['close']
return {
'final_capital': capital,
'return_pct': (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100,
'total_trades': len(trades),
'trades': trades
}
# =============================================================================
# MAIN
# =============================================================================
if __name__ == "__main__":
print(f"Fetching {SYMBOL} data...")
df = fetch_data(days=365)
print(f"Got {len(df)} candles")
print("Calculating indicators...")
df = calculate_indicators(df)
print("Generating signals...")
df = generate_signals(df)
print("Running backtest...")
results = backtest(df)
print("\n" + "="*50)
print("BACKTEST RESULTS")
print("="*50)
print(f"Final Capital: ${results['final_capital']:,.2f}")
print(f"Return: {results['return_pct']:+.2f}%")
print(f"Total Trades: {results['total_trades']}")
#!/usr/bin/env python3
"""
Trading Strategy
================
Auto-generated by Crypto Backtest Skill
https://github.com/0xrikt/crypto-skills
Asset: BTC/USDT
Timeframe: 6h
Entry: rsi<35
Exit: rsi>70
Stop Loss: 8.0%
Take Profit: 20.0%
"""
import ccxt
import pandas as pd
import pandas_ta as ta
from datetime import datetime, timedelta
# =============================================================================
# CONFIGURATION
# =============================================================================
SYMBOL = "BTC/USDT"
TIMEFRAME = "6h"
EXCHANGE = "binance"
# Risk Management
INITIAL_CAPITAL = 10000.0
POSITION_SIZE_PCT = 100.0 # % of portfolio per trade
STOP_LOSS_PCT = 8.0
TAKE_PROFIT_PCT = 20.0
COMMISSION_PCT = 0.1
# =============================================================================
# DATA FETCHING
# =============================================================================
def fetch_data(days: int = 365) -> pd.DataFrame:
"""Fetch historical OHLCV data."""
exchange = getattr(ccxt, EXCHANGE)({'enableRateLimit': True})
since = exchange.parse8601((datetime.utcnow() - timedelta(days=days)).isoformat())
all_ohlcv = []
while True:
ohlcv = exchange.fetch_ohlcv(SYMBOL, TIMEFRAME, since=since, limit=1000)
if not ohlcv:
break
all_ohlcv.extend(ohlcv)
since = ohlcv[-1][0] + 1
if len(ohlcv) < 1000:
break
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
# =============================================================================
# INDICATORS
# =============================================================================
def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
"""Calculate technical indicators."""
df = df.copy()
# RSI
df['rsi'] = ta.rsi(df['close'], length=14)
# MACD
macd = ta.macd(df['close'], fast=12, slow=26, signal=9)
if macd is not None:
df['macd'] = macd.iloc[:, 0]
df['macd_signal'] = macd.iloc[:, 2]
# Moving Averages
df['sma50'] = ta.sma(df['close'], length=50)
df['ema21'] = ta.ema(df['close'], length=21)
# Bollinger Bands
bb = ta.bbands(df['close'], length=20, std=2.0)
if bb is not None:
df['bb_upper'] = bb.iloc[:, 2]
df['bb_lower'] = bb.iloc[:, 0]
return df
# =============================================================================
# SIGNAL GENERATION
# =============================================================================
def generate_signals(df: pd.DataFrame) -> pd.DataFrame:
"""Generate entry and exit signals."""
df = df.copy()
# Entry conditions: rsi<35
entry = pd.Series(True, index=df.index)
# TODO: Customize entry conditions
entry &= df['rsi'] < 30 # Example
# Exit conditions: rsi>70
exit_signal = pd.Series(False, index=df.index)
# TODO: Customize exit conditions
exit_signal |= df['rsi'] > 70 # Example
df['entry_signal'] = entry.astype(int)
df['exit_signal'] = exit_signal.astype(int)
return df
# =============================================================================
# BACKTEST
# =============================================================================
def backtest(df: pd.DataFrame) -> dict:
"""Run backtest simulation."""
capital = INITIAL_CAPITAL
position = 0.0
entry_price = 0.0
trades = []
for timestamp, row in df.iterrows():
price = row['close']
# Check stop-loss / take-profit
if position > 0:
pnl_pct = (price - entry_price) / entry_price * 100
if pnl_pct <= -STOP_LOSS_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'stop_loss'})
position = 0
elif pnl_pct >= TAKE_PROFIT_PCT:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'take_profit'})
position = 0
# Entry
if row['entry_signal'] == 1 and position == 0:
position_value = capital * POSITION_SIZE_PCT / 100
position = position_value / price
entry_price = price
capital -= position_value * (1 + COMMISSION_PCT / 100)
# Exit
elif row['exit_signal'] == 1 and position > 0:
proceeds = position * price * (1 - COMMISSION_PCT / 100)
pnl_pct = (price - entry_price) / entry_price * 100
capital += proceeds
trades.append({'pnl_pct': pnl_pct, 'reason': 'signal'})
position = 0
# Close remaining
if position > 0:
capital += position * df.iloc[-1]['close']
return {
'final_capital': capital,
'return_pct': (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100,
'total_trades': len(trades),
'trades': trades
}
# =============================================================================
# MAIN
# =============================================================================
if __name__ == "__main__":
print(f"Fetching {SYMBOL} data...")
df = fetch_data(days=365)
print(f"Got {len(df)} candles")
print("Calculating indicators...")
df = calculate_indicators(df)
print("Generating signals...")
df = generate_signals(df)
print("Running backtest...")
results = backtest(df)
print("\n" + "="*50)
print("BACKTEST RESULTS")
print("="*50)
print(f"Final Capital: ${results['final_capital']:,.2f}")
print(f"Return: {results['return_pct']:+.2f}%")
print(f"Total Trades: {results['total_trades']}")
🚀 Crypto Backtest Skill
Validate your trading ideas in minutes
Transform natural language trading ideas into validated strategies with professional backtesting, beautiful reports, and runnable code.
⚠️ Important: This tool supports SPOT trading strategies only. No leverage, no shorting, no futures/perpetual contracts. All strategies are long-only (buy → hold → sell).
✨ Features
- Natural Language Input - Describe strategies like "buy BTC when oversold, sell when overbought"
- Automatic Strategy Completion - AI translates vague ideas into specific technical conditions
- User Confirmation - Review and modify the strategy before running
- Professional Backtesting - Real historical data from 200+ exchanges via CCXT
- Beautiful Reports - Interactive HTML reports with Plotly charts
- Runnable Code - Get Python scripts you can run directly
📦 Installation
pip install -r requirements.txt🎯 Quick Start
As a Skill (Claude Desktop / AI Agent)
Just describe your trading idea:
"I want to buy ETH when it's oversold and there's fear in the market,
then sell when it becomes overbought"The AI will: 1. Translate this into technical conditions (RSI < 30, etc.) 2. Show you the complete strategy for confirmation 3. Run the backtest on real historical data 4. Generate an interactive HTML report 5. Provide runnable Python code
Command Line
python src/backtest.py \
--symbol BTC/USDT \
--timeframe 4h \
--days 365 \
--entry "rsi<30,price<sma50" \
--exit "rsi>70" \
--stop-loss 5 \
--take-profit 15 \
--output my_strategy_report.html📊 Supported Indicators
Momentum Indicators
| Indicator | Syntax | Example |
|---|---|---|
| RSI | rsi | rsi<30, rsi>70 |
| MACD | macd, macd_signal, macd_hist | macd>macd_signal |
| Stochastic | stoch_k, stoch_d | stoch_k<20 |
| Williams %R | williams_r | williams_r<-80 |
| CCI | cci | cci<-100 |
| MFI | mfi | mfi<20 |
| ROC | roc | roc>0 |
Trend Indicators
| Indicator | Syntax | Example |
|---|---|---|
| SMA | sma{period} | price>sma50, price>sma200 |
| EMA | ema{period} | price<ema21, ema9>ema21 |
| ADX | adx, plus_di, minus_di | adx>25 |
Volatility Indicators
| Indicator | Syntax | Example |
|---|---|---|
| Bollinger Bands | bb_upper, bb_lower, bb_middle | price<bb_lower |
| BB Width | bb_width | bb_width<0.1 |
| BB %B | bb_percent_b | bb_percent_b<0.2 |
| ATR | atr, atr_pct | atr_pct>3 |
Volume Indicators
| Indicator | Syntax | Example |
|---|---|---|
| Volume MA | volume_ma | volume>volume_ma |
| Volume Ratio | volume_ratio | volume_ratio>2 |
| OBV | obv | - |
Price Action
| Indicator | Syntax | Example |
|---|---|---|
| Rolling High/Low | high_20, low_50, high_90 | price>high_20 |
| Drawdown | drawdown, drawdown_50 | drawdown<-25 |
| Price Position | price_position_90 | price_position_90<0.3 |
| Distance from MA | dist_sma50, dist_sma200 | dist_sma200<-10 |
Special Conditions
| Pattern | Syntax | Example |
|---|---|---|
| Crossover | {ind1}_cross_above_{ind2} | ema9_cross_above_ema21 |
| Crossunder | {ind1}_cross_below_{ind2} | price_cross_below_sma200 |
| Turning Up | {ind}_turning_up | rsi_turning_up |
| Turning Down | {ind}_turning_down | macd_hist_turning_down |
| Consecutive | {cond}_for_{n} | rsi<30_for_3 |
| Percentage Ref | {ind}_{pct}pct | price<sma200_98pct |
📈 Sample Output
The backtest generates:
1. HTML Report - Interactive charts showing:
- Equity curve
- Drawdown
- Price chart with buy/sell signals
- Performance metrics
- Trade history
2. Python Script - Runnable strategy code that you can:
- Customize further
- Run on different assets
- Deploy to production
🎨 Report Preview
The HTML report features:
- Dark theme optimized for trading
- Interactive Plotly charts
- Key metrics at a glance
- Full trade history
- Shareable design
📋 CLI Options
--symbol Trading pair (default: BTC/USDT)
--timeframe Candle timeframe: 1m, 5m, 15m, 1h, 4h, 1d (default: 4h)
--days Backtest period in days (default: 365)
--exchange Exchange to fetch data from (default: okx). Options: okx, binance, kucoin, kraken, bybit. Note: OKX ~90 day limit
--entry Entry conditions, comma-separated (default: rsi<30)
--exit Exit conditions, comma-separated (default: rsi>70)
--stop-loss Stop loss percentage (default: 5)
--take-profit Take profit percentage (default: 15)
--position-size Position size as % of portfolio, NOT dollar amount (default: 10 = 10% per trade)
--initial-capital Starting capital (default: 10000)
--commission Commission percentage (default: 0.1)
--output Output HTML file path
--name Strategy name for the report
--lang Report language: en or zh (default: en)🧠 Smart DCA
For dollar-cost averaging strategies with valuation-based allocation:
python src/smart_dca.py \
--symbol BTC/USDT \
--days 1095 \
--base-amount 200 \
--frequency 7 \
--output smart_dca_report.html \
--lang zhSmart DCA uses a multi-factor valuation model:
- RSI, SMA(200), Bollinger Bands, Drawdown, MACD
- Adjusts investment amount based on valuation score
🔄 Pair Trading / Relative Strength
For strategies that trade based on relative performance between two assets:
python src/pair_trading.py \
--symbol-a BTC/USDT \
--symbol-b ETH/USDT \
--days 365 \
--timeframe 4h \
--lookback 20 \
--threshold 10 \
--exit-threshold 2 \
--output pair_trading_report.html \
--lang enStrategy Logic:
- When BTC significantly outperforms ETH (spread > threshold) → Long ETH (expect catch-up)
- When ETH significantly outperforms BTC (spread < -threshold) → Long BTC (expect catch-up)
- Exit when spread returns to mean (within ±exit-threshold%)
Parameters:
| Parameter | Description | Default |
|---|---|---|
--lookback | Period for calculating relative performance | 20 |
--threshold | Entry threshold (spread % deviation) | 10 |
--exit-threshold | Exit threshold (spread % to close) | 2 |
Note: This is a spot-only, long-only strategy. We go long the underperformer expecting mean reversion.
⚠️ Exchange Data Limits
Different exchanges have different historical data limits!
| Exchange | Approximate Limit | Notes |
|---|---|---|
| OKX | ~60-90 days | Default. Good for short-term backtests |
| KuCoin | ~200 days | Good alternative for medium-term |
| Binance | 1000+ days | Most data, but blocked in some regions |
| Bybit | ~200 days | Good alternative |
For backtests > 90 days, use --exchange kucoin or --exchange binance (if accessible).
🛠 Tech Stack
- Data: CCXT (200+ exchanges)
- Indicators: pandas-ta (130+ indicators)
- Backtesting: Custom vectorized engine
- Visualization: Plotly
- Reports: Self-contained HTML
⚠️ Disclaimer
This tool is for educational and research purposes only. Past performance does not guarantee future results. Always do your own research before trading with real money.
📄 License
MIT
---
Like this tool? Star the repo and share your backtest results! ⭐
ccxt>=4.0.0
pandas>=2.0.0
pandas-ta>=0.3.14b
numpy>=1.24.0
plotly>=5.15.0
#!/usr/bin/env python3
"""
Pair Trading / Relative Strength Strategy Backtest
This strategy compares two assets (e.g., BTC and ETH) and trades based on
the assumption that their trends will eventually align.
Strategy Logic:
- If Asset A significantly outperforms Asset B → Long Asset B (expect catch-up)
- If Asset B significantly outperforms Asset A → Long Asset A (expect catch-up)
SPOT ONLY: All trades are long-only, no shorting, no leverage.
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from typing import Dict, List, Tuple, Optional
import ccxt
import numpy as np
import pandas as pd
import pandas_ta as ta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Language labels
LABELS = {
'en': {
'title': 'Pair Trading Backtest Report',
'subtitle': 'Relative Strength Mean Reversion',
'strategy_summary': 'Strategy Summary',
'original_strategy_idea': 'Original Strategy Idea',
'no_description_provided': 'Trade underperforming asset when spread deviates from mean',
'symbol_a': 'Symbol A',
'symbol_b': 'Symbol B',
'timeframe': 'Timeframe',
'backtest_period': 'Backtest Period',
'to': 'to',
'days': 'days',
'lookback': 'Lookback Period',
'threshold': 'Entry Threshold',
'exit_threshold': 'Exit Threshold',
'initial_capital': 'Initial Capital',
'position_size': 'Position Size',
'stop_loss': 'Stop Loss',
'take_profit': 'Take Profit',
'commission': 'Commission',
'strategy_logic': 'Strategy Logic',
'logic_desc': 'When {a} outperforms {b} by more than {t}%, long {b} (expect catch-up). Vice versa.',
'exit_logic': 'Exit when spread returns within ±{t}% of mean, or stop-loss/take-profit hit.',
'performance_metrics': 'Performance Metrics',
'total_return': 'Total Return',
'buy_hold_a': 'Buy & Hold {a}',
'buy_hold_b': 'Buy & Hold {b}',
'max_drawdown': 'Max Drawdown',
'sharpe_ratio': 'Sharpe Ratio',
'total_trades': 'Total Trades',
'win_rate': 'Win Rate',
'profit_factor': 'Profit Factor',
'trades_on_a': 'Trades on {a}',
'trades_on_b': 'Trades on {b}',
'price_comparison': 'Price Comparison (Normalized)',
'relative_spread': 'Relative Performance Spread',
'equity_curve': 'Portfolio Equity Curve',
'trade_history': 'Trade History',
'date': 'Date',
'action': 'Action',
'asset': 'Asset',
'price': 'Price',
'amount': 'Amount',
'pnl': 'P&L',
'buy': 'BUY',
'sell': 'SELL',
'tagline': 'Validate your trading ideas in minutes',
'share_cta': 'Found this useful? Share it with fellow traders!',
'generated': 'Generated',
'disclaimer': 'Past performance ≠ future results',
},
'zh': {
'title': '配对交易回测报告',
'subtitle': '相对强弱均值回归策略',
'strategy_summary': '策略概览',
'original_strategy_idea': '原始策略思路',
'no_description_provided': '当价差偏离均值时,做多表现落后的资产',
'symbol_a': '资产 A',
'symbol_b': '资产 B',
'timeframe': '时间周期',
'backtest_period': '回测区间',
'to': '至',
'days': '天',
'lookback': '回溯周期',
'threshold': '入场阈值',
'exit_threshold': '出场阈值',
'initial_capital': '初始资金',
'position_size': '仓位比例',
'stop_loss': '止损',
'take_profit': '止盈',
'commission': '手续费',
'strategy_logic': '策略逻辑',
'logic_desc': '当 {a} 相对 {b} 跑赢超过 {t}% 时,做多 {b}(预期追涨)。反之亦然。',
'exit_logic': '当价差回归至均值 ±{t}% 内,或触发止损/止盈时平仓。',
'performance_metrics': '绩效指标',
'total_return': '总收益率',
'buy_hold_a': '持有 {a}',
'buy_hold_b': '持有 {b}',
'max_drawdown': '最大回撤',
'sharpe_ratio': '夏普比率',
'total_trades': '总交易次数',
'win_rate': '胜率',
'profit_factor': '盈亏比',
'trades_on_a': '{a} 交易次数',
'trades_on_b': '{b} 交易次数',
'price_comparison': '价格对比(标准化)',
'relative_spread': '相对强弱价差',
'equity_curve': '账户权益曲线',
'trade_history': '交易记录',
'date': '日期',
'action': '操作',
'asset': '资产',
'price': '价格',
'amount': '数量',
'pnl': '盈亏',
'buy': '买入',
'sell': '卖出',
'tagline': '几分钟验证你的交易策略想法',
'share_cta': '觉得有用?分享给其他交易者吧!',
'generated': '生成时间',
'disclaimer': '历史表现不代表未来收益',
}
}
def fetch_data(symbol: str, days: int, timeframe: str = '4h', exchange_id: str = 'okx') -> pd.DataFrame:
"""Fetch OHLCV data from exchange."""
print(f"📊 Fetching {symbol} data ({days} days, {timeframe})...")
exchange_class = getattr(ccxt, exchange_id)
exchange = exchange_class({'enableRateLimit': True})
since = exchange.parse8601((datetime.now(tz=None) - timedelta(days=days)).isoformat())
all_ohlcv = []
while True:
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
if not ohlcv:
break
all_ohlcv.extend(ohlcv)
since = ohlcv[-1][0] + 1
if len(ohlcv) < 1000:
break
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
df = df[~df.index.duplicated(keep='first')]
print(f" ✓ Loaded {len(df)} candles for {symbol}")
# Validate data - warn if significantly less than requested
if len(df) > 1:
actual_days = (df.index[-1] - df.index[0]).days
if actual_days < days * 0.5:
print()
print(f"⚠️ WARNING: {symbol} - Received much less data than requested!")
print(f" Requested: {days} days, Received: {actual_days} days")
print(" 💡 TIP: OKX ~90 day limit. Use --exchange kucoin or binance for longer backtests")
print()
return df
def calculate_spread(df_a: pd.DataFrame, df_b: pd.DataFrame, lookback: int = 20) -> pd.DataFrame:
"""
Calculate relative performance spread between two assets.
Spread = (Return_A - Return_B) over lookback period
Positive spread = A outperforming B
Negative spread = B outperforming A
"""
# Align dataframes on common index
common_idx = df_a.index.intersection(df_b.index)
df_a = df_a.loc[common_idx].copy()
df_b = df_b.loc[common_idx].copy()
# Calculate rolling returns
df_a['return'] = df_a['close'].pct_change(lookback) * 100
df_b['return'] = df_b['close'].pct_change(lookback) * 100
# Create combined dataframe
df = pd.DataFrame(index=common_idx)
df['price_a'] = df_a['close']
df['price_b'] = df_b['close']
df['return_a'] = df_a['return']
df['return_b'] = df_b['return']
df['spread'] = df['return_a'] - df['return_b']
# Calculate spread statistics
df['spread_mean'] = df['spread'].rolling(window=lookback*2).mean()
df['spread_std'] = df['spread'].rolling(window=lookback*2).std()
df['spread_zscore'] = (df['spread'] - df['spread_mean']) / df['spread_std']
# Normalize prices for comparison chart
df['price_a_norm'] = df['price_a'] / df['price_a'].iloc[0] * 100
df['price_b_norm'] = df['price_b'] / df['price_b'].iloc[0] * 100
return df.dropna()
def generate_signals(df: pd.DataFrame, threshold: float = 10.0, exit_threshold: float = 2.0, only_long: str = 'both') -> pd.DataFrame:
"""
Generate trading signals based on spread deviation.
- Long B when spread > threshold (A outperforming, expect B to catch up)
- Long A when spread < -threshold (B outperforming, expect A to catch up)
- Exit when spread returns to within ±exit_threshold of mean
Args:
only_long: 'A' = only long A, 'B' = only long B, 'both' = trade both directions
"""
df = df.copy()
df['signal'] = 0 # 0 = no position, 1 = long A, 2 = long B
df['target_asset'] = ''
position = 0 # Current position
for i in range(1, len(df)):
spread = df['spread'].iloc[i]
if position == 0:
# No position - check for entry
if spread > threshold and only_long in ['B', 'both']:
# A significantly outperforming B → Long B
position = 2
df.iloc[i, df.columns.get_loc('signal')] = 2
df.iloc[i, df.columns.get_loc('target_asset')] = 'B'
elif spread < -threshold and only_long in ['A', 'both']:
# B significantly outperforming A → Long A
position = 1
df.iloc[i, df.columns.get_loc('signal')] = 1
df.iloc[i, df.columns.get_loc('target_asset')] = 'A'
else:
# Have position - check for exit
df.iloc[i, df.columns.get_loc('signal')] = position
if abs(spread) < exit_threshold:
# Spread reverted to mean - exit
position = 0
df.iloc[i, df.columns.get_loc('signal')] = 0
return df
def run_backtest(df: pd.DataFrame, config: Dict) -> Tuple[Dict, List[Dict], pd.DataFrame]:
"""Run backtest simulation."""
initial_capital = config.get('initial_capital', 10000)
position_pct = config.get('position_size', 20) / 100
stop_loss_pct = config.get('stop_loss', 10) / 100
take_profit_pct = config.get('take_profit', 25) / 100
commission_pct = config.get('commission', 0.1) / 100
# Track portfolio
cash = initial_capital
position_asset = None # 'A' or 'B'
position_qty = 0
entry_price = 0
equity_curve = []
trades = []
for i in range(len(df)):
row = df.iloc[i]
current_signal = row['signal']
# Get current prices
price_a = row['price_a']
price_b = row['price_b']
# Calculate current equity
if position_asset == 'A':
position_value = position_qty * price_a
elif position_asset == 'B':
position_value = position_qty * price_b
else:
position_value = 0
current_equity = cash + position_value
equity_curve.append({
'timestamp': df.index[i],
'equity': current_equity,
'cash': cash,
'position_value': position_value
})
# Check stop-loss / take-profit if in position
if position_asset:
current_price = price_a if position_asset == 'A' else price_b
pnl_pct = (current_price - entry_price) / entry_price
if pnl_pct <= -stop_loss_pct or pnl_pct >= take_profit_pct:
# Exit position
exit_value = position_qty * current_price * (1 - commission_pct)
pnl = exit_value - (entry_price * position_qty)
cash += exit_value
trades.append({
'date': df.index[i],
'action': 'SELL',
'asset': position_asset,
'price': current_price,
'qty': position_qty,
'value': exit_value,
'pnl': pnl,
'reason': 'stop_loss' if pnl_pct <= -stop_loss_pct else 'take_profit'
})
position_asset = None
position_qty = 0
entry_price = 0
continue
# Process signals
if current_signal == 0 and position_asset:
# Exit signal - close position
current_price = price_a if position_asset == 'A' else price_b
exit_value = position_qty * current_price * (1 - commission_pct)
pnl = exit_value - (entry_price * position_qty)
cash += exit_value
trades.append({
'date': df.index[i],
'action': 'SELL',
'asset': position_asset,
'price': current_price,
'qty': position_qty,
'value': exit_value,
'pnl': pnl,
'reason': 'signal_exit'
})
position_asset = None
position_qty = 0
entry_price = 0
elif current_signal > 0 and not position_asset:
# Entry signal
target = 'A' if current_signal == 1 else 'B'
price = price_a if target == 'A' else price_b
# Calculate position size
position_value = cash * position_pct
position_qty = (position_value * (1 - commission_pct)) / price
entry_price = price
position_asset = target
cash -= position_value
trades.append({
'date': df.index[i],
'action': 'BUY',
'asset': target,
'price': price,
'qty': position_qty,
'value': position_value,
'pnl': 0,
'reason': 'signal_entry'
})
# Close any remaining position at end
if position_asset:
final_price = df.iloc[-1]['price_a'] if position_asset == 'A' else df.iloc[-1]['price_b']
exit_value = position_qty * final_price * (1 - commission_pct)
pnl = exit_value - (entry_price * position_qty)
cash += exit_value
trades.append({
'date': df.index[-1],
'action': 'SELL',
'asset': position_asset,
'price': final_price,
'qty': position_qty,
'value': exit_value,
'pnl': pnl,
'reason': 'end_of_backtest'
})
# Calculate metrics
equity_df = pd.DataFrame(equity_curve).set_index('timestamp')
final_equity = equity_df['equity'].iloc[-1]
total_return = (final_equity - initial_capital) / initial_capital * 100
# Buy & Hold returns
bh_return_a = (df['price_a'].iloc[-1] / df['price_a'].iloc[0] - 1) * 100
bh_return_b = (df['price_b'].iloc[-1] / df['price_b'].iloc[0] - 1) * 100
# Max drawdown
rolling_max = equity_df['equity'].cummax()
drawdown = (equity_df['equity'] - rolling_max) / rolling_max * 100
max_drawdown = drawdown.min()
# Trade statistics
if trades:
sell_trades = [t for t in trades if t['action'] == 'SELL']
winning_trades = [t for t in sell_trades if t['pnl'] > 0]
losing_trades = [t for t in sell_trades if t['pnl'] < 0]
win_rate = len(winning_trades) / len(sell_trades) * 100 if sell_trades else 0
total_profit = sum(t['pnl'] for t in winning_trades)
total_loss = abs(sum(t['pnl'] for t in losing_trades))
profit_factor = total_profit / total_loss if total_loss > 0 else float('inf')
trades_a = len([t for t in trades if t['asset'] == 'A' and t['action'] == 'BUY'])
trades_b = len([t for t in trades if t['asset'] == 'B' and t['action'] == 'BUY'])
else:
win_rate = 0
profit_factor = 0
trades_a = 0
trades_b = 0
# Sharpe ratio (annualized)
returns = equity_df['equity'].pct_change().dropna()
sharpe = (returns.mean() / returns.std()) * np.sqrt(252 * 6) if returns.std() > 0 else 0 # 6 for 4h timeframe
metrics = {
'initial_capital': initial_capital,
'final_equity': final_equity,
'total_return': total_return,
'buy_hold_return_a': bh_return_a,
'buy_hold_return_b': bh_return_b,
'max_drawdown': max_drawdown,
'sharpe_ratio': sharpe,
'total_trades': len([t for t in trades if t['action'] == 'BUY']),
'win_rate': win_rate,
'profit_factor': profit_factor,
'trades_a': trades_a,
'trades_b': trades_b
}
return metrics, trades, equity_df
def generate_html_report(df: pd.DataFrame, metrics: Dict, trades: List[Dict],
equity_df: pd.DataFrame, config: Dict, lang: str = 'en') -> str:
"""Generate professional HTML report matching single-asset style."""
L = LABELS.get(lang, LABELS['en'])
symbol_a = config.get('symbol_a', 'BTC/USDT')
symbol_b = config.get('symbol_b', 'ETH/USDT')
name_a = symbol_a.split('/')[0]
name_b = symbol_b.split('/')[0]
# Calculate additional metrics
initial_capital = config.get('initial_capital', 10000)
final_equity = metrics.get('final_equity', initial_capital)
total_profit = final_equity - initial_capital
# Calculate drawdowns for chart
equities = equity_df['equity'].tolist()
peak = equities[0]
drawdowns = []
for eq in equities:
if eq > peak:
peak = eq
dd = (peak - eq) / peak * 100
drawdowns.append(-dd)
# Prepare trade markers for spread chart and price chart
buy_times = []
buy_spreads = []
buy_prices_norm = []
sell_times = []
sell_spreads = []
sell_prices_norm = []
for t in trades:
trade_time = t['date'].isoformat()
# Find closest values
if t['date'] in df.index:
spread_val = df.loc[t['date'], 'spread']
price_norm = df.loc[t['date'], 'price_b_norm'] # ETH normalized price
else:
# Find nearest index
idx = df.index.get_indexer([t['date']], method='nearest')[0]
spread_val = df.iloc[idx]['spread']
price_norm = df.iloc[idx]['price_b_norm']
if t['action'] == 'BUY':
buy_times.append(trade_time)
buy_spreads.append(spread_val)
buy_prices_norm.append(price_norm)
else:
sell_times.append(trade_time)
sell_spreads.append(spread_val)
sell_prices_norm.append(price_norm)
# Timestamps for charts
timestamps = [ts.isoformat() for ts in df.index]
equity_timestamps = [ts.isoformat() for ts in equity_df.index]
# Format trades table (paired entry/exit)
trades_html = ''
buy_trades = [t for t in trades if t['action'] == 'BUY']
sell_trades = [t for t in trades if t['action'] == 'SELL']
for i, (buy, sell) in enumerate(zip(buy_trades, sell_trades)):
pnl_class = 'positive' if sell['pnl'] > 0 else 'negative'
pnl_pct = (sell['price'] - buy['price']) / buy['price'] * 100
asset_name = name_b if buy['asset'] == 'B' else name_a
trades_html += f'''
<tr>
<td>{buy['date'].strftime('%Y-%m-%d %H:%M')}</td>
<td>{sell['date'].strftime('%Y-%m-%d %H:%M')}</td>
<td><span class="asset-badge">{asset_name}</span></td>
<td>${buy['value']:,.2f}</td>
<td>{buy['qty']:,.6f}</td>
<td>${buy['price']:,.2f}</td>
<td>${sell['price']:,.2f}</td>
<td class="{pnl_class}">{pnl_pct:+.2f}%</td>
<td class="{pnl_class}">${sell['pnl']:+,.2f}</td>
</tr>'''
# Determine colors
return_class = 'positive' if metrics['total_return'] > 0 else 'negative'
profit_class = 'positive' if total_profit > 0 else 'negative'
# Only long info
only_long = config.get('only_long', 'both')
if only_long == 'B':
only_long_text = f"只做多 {name_b}" if lang == 'zh' else f"Long {name_b} only"
elif only_long == 'A':
only_long_text = f"只做多 {name_a}" if lang == 'zh' else f"Long {name_a} only"
else:
only_long_text = "双向交易" if lang == 'zh' else "Both directions"
html = f'''<!DOCTYPE html>
<html lang="{lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{L['title']} | {name_a} vs {name_b}</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {{
--bg-void: #f8fafc;
--bg-deep: #ffffff;
--bg-surface: #ffffff;
--bg-elevated: #f1f5f9;
--bg-hover: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--accent-cyan: #0ea5e9;
--accent-btc: #f7931a;
--accent-eth: #627eea;
--accent-green: #10b981;
--accent-red: #ef4444;
--accent-gold: #f59e0b;
--accent-purple: #8b5cf6;
--gradient-pair: linear-gradient(135deg, #f7931a 0%, #627eea 100%);
--border-subtle: #e2e8f0;
--border-accent: rgba(14,165,233,0.4);
--glow-cyan: 0 4px 20px rgba(14,165,233,0.15);
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Space Grotesk', -apple-system, sans-serif;
background: var(--bg-void);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}}
.bg-pattern {{
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background:
radial-gradient(ellipse at 20% 20%, rgba(247,147,26,0.05) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(98,126,234,0.05) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}}
.container {{
position: relative;
z-index: 1;
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px;
}}
/* Header */
.header {{
text-align: center;
padding: 60px 0;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 48px;
}}
.header-subtitle {{
font-size: 0.85rem;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
}}
.header-title {{
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 700;
margin-bottom: 16px;
background: var(--gradient-pair);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.header-desc {{
max-width: 700px;
margin: 0 auto 20px;
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1.5;
}}
.header-meta {{
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
color: var(--text-secondary);
font-size: 0.85rem;
}}
.header-meta span {{
display: flex;
align-items: center;
gap: 6px;
}}
.header-meta .dot {{
width: 5px;
height: 5px;
background: var(--accent-cyan);
border-radius: 50%;
}}
/* Strategy Compact Grid - 3x2 */
.section {{
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 20px;
padding: 32px;
margin-bottom: 32px;
}}
.section-header {{
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}}
.section-icon {{
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
border-radius: 10px;
font-size: 1.2rem;
}}
.section h2 {{
font-size: 1.25rem;
font-weight: 600;
}}
.strategy-compact {{
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, minmax(130px, auto));
gap: 12px;
}}
@media (max-width: 900px) {{
.strategy-compact {{
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, minmax(130px, auto));
}}
}}
@media (max-width: 600px) {{
.strategy-compact {{
grid-template-columns: 1fr;
grid-template-rows: repeat(6, auto);
}}
}}
.strategy-block {{
background: var(--bg-elevated);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
}}
.strategy-block h4 {{
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}}
.param-row {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 0;
font-size: 0.85rem;
}}
.param-row span {{
color: var(--text-secondary);
font-size: 0.8rem;
}}
.param-row code {{
font-family: 'JetBrains Mono', monospace;
background: var(--bg-deep);
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
color: var(--accent-cyan);
}}
.param-row code.green {{ color: var(--accent-green); }}
.param-row code.red {{ color: var(--accent-red); }}
.param-row code.btc {{ color: var(--accent-btc); }}
.param-row code.eth {{ color: var(--accent-eth); }}
/* Metrics Table */
.metrics-table-container {{
overflow-x: auto;
margin-bottom: 32px;
}}
.metrics-table {{
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}}
.metrics-table thead th {{
background: var(--bg-elevated);
padding: 16px 20px;
text-align: center;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.75rem;
border-bottom: 2px solid var(--border-subtle);
}}
.metrics-table tbody tr {{
border-bottom: 1px solid var(--border-subtle);
}}
.metrics-table tbody tr:hover {{
background: var(--bg-elevated);
}}
.metrics-table td {{
padding: 14px 20px;
}}
.metrics-table .metric-name {{
color: var(--text-secondary);
font-weight: 500;
}}
.metrics-table .metric-val {{
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
text-align: right;
}}
.metrics-table .metric-val.positive {{ color: var(--accent-green); }}
.metrics-table .metric-val.negative {{ color: var(--accent-red); }}
/* Charts */
.chart-container {{
background: var(--bg-deep);
border-radius: 12px;
padding: 16px;
margin-bottom: 32px;
min-height: 320px;
position: relative;
overflow: hidden;
}}
.chart-container:last-child {{
margin-bottom: 0;
}}
.chart-label {{
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
padding-left: 8px;
}}
.charts-row {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
}}
@media (max-width: 992px) {{
.charts-row {{
grid-template-columns: 1fr;
}}
}}
.chart-half {{
min-height: auto;
}}
.chart-half .chart-container {{
margin-bottom: 0;
min-height: 280px;
}}
.trades-wrapper {{
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid var(--border-subtle);
}}
/* Trades Table */
.trades-table {{
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}}
.trades-table th {{
text-align: left;
padding: 14px 16px;
background: var(--bg-elevated);
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 0.75rem;
}}
.trades-table th:first-child {{ border-radius: 8px 0 0 8px; }}
.trades-table th:last-child {{ border-radius: 0 8px 8px 0; }}
.trades-table td {{
padding: 14px 16px;
border-bottom: 1px solid var(--border-subtle);
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
}}
.trades-table tr:hover td {{
background: var(--bg-hover);
}}
.positive {{ color: var(--accent-green); }}
.negative {{ color: var(--accent-red); }}
.asset-badge {{
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
background: linear-gradient(135deg, rgba(98,126,234,0.15) 0%, rgba(98,126,234,0.25) 100%);
color: var(--accent-eth);
}}
/* Footer */
.footer {{
margin-top: 48px;
padding: 32px 24px;
text-align: center;
border-top: 1px solid var(--border-subtle);
background: var(--bg-surface);
}}
.footer-tagline {{
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 16px;
}}
.footer-github {{
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: all 0.2s ease;
}}
.footer-github:hover {{
background: var(--bg-hover);
border-color: var(--border-accent);
color: var(--text-primary);
}}
.footer-note {{
margin-top: 16px;
color: var(--text-muted);
font-size: 0.75rem;
}}
</style>
</head>
<body>
<div class="bg-pattern"></div>
<div class="container">
<header class="header">
<div class="header-subtitle">{L['title']}</div>
<h1 class="header-title">{name_a} ↔ {name_b}</h1>
<p class="header-desc">"{config.get('description', L['no_description_provided'])}"</p>
<div class="header-meta">
<span><div class="dot"></div>{config.get('timeframe', '4h')}</span>
<span><div class="dot"></div>{df.index.min().strftime('%Y-%m-%d')} → {df.index.max().strftime('%Y-%m-%d')}</span>
<span><div class="dot"></div>{metrics['total_trades']} trades</span>
</div>
</header>
<!-- Strategy Summary - Compact 3x2 Grid -->
<section class="section">
<div class="section-header">
<div class="section-icon">📋</div>
<h2>{L['strategy_summary']}</h2>
</div>
<div class="strategy-compact">
<div class="strategy-block">
<h4>📊 DATA</h4>
<div class="param-row"><span>{L['symbol_a']}</span><code class="btc">{name_a}</code></div>
<div class="param-row"><span>{L['symbol_b']}</span><code class="eth">{name_b}</code></div>
<div class="param-row"><span>{L['timeframe']}</span><code>{config.get('timeframe', '4h')}</code></div>
</div>
<div class="strategy-block">
<h4>🟢 ENTRY</h4>
<div class="param-row"><span>{L['threshold']}</span><code class="green">spread > {config.get('threshold', 3)}%</code></div>
<div class="param-row"><span>Direction</span><code>{only_long_text}</code></div>
</div>
<div class="strategy-block">
<h4>🔴 EXIT</h4>
<div class="param-row"><span>{L['exit_threshold']}</span><code>|spread| < {config.get('exit_threshold', 1)}%</code></div>
<div class="param-row"><span>{L['stop_loss']}</span><code class="red">-{config.get('stop_loss', 20)}%</code></div>
<div class="param-row"><span>{L['take_profit']}</span><code class="green">+{config.get('take_profit', 20)}%</code></div>
</div>
<div class="strategy-block">
<h4>💰 CAPITAL</h4>
<div class="param-row"><span>{L['initial_capital']}</span><code>${config.get('initial_capital', 10000):,}</code></div>
<div class="param-row"><span>{L['position_size']}</span><code>{config.get('position_size', 20)}%</code></div>
<div class="param-row"><span>{L['commission']}</span><code>{config.get('commission', 0.1)}%</code></div>
</div>
<div class="strategy-block">
<h4>📅 PERIOD</h4>
<div class="param-row"><span>{'实际天数' if lang == 'zh' else 'Days'}</span><code>{(df.index.max() - df.index.min()).days}</code></div>
<div class="param-row"><span>{'开始' if lang == 'zh' else 'Start'}</span><code>{df.index.min().strftime('%m-%d')}</code></div>
<div class="param-row"><span>{'结束' if lang == 'zh' else 'End'}</span><code>{df.index.max().strftime('%m-%d')}</code></div>
</div>
<div class="strategy-block">
<h4>⚙️ EXECUTION</h4>
<div class="param-row"><span>Leverage</span><code>1x</code></div>
<div class="param-row"><span>Order</span><code>Market</code></div>
<div class="param-row"><span>Side</span><code>Long Only</code></div>
</div>
</div>
</section>
<!-- Trade History with Charts -->
<section class="section">
<div class="section-header">
<div class="section-icon">📈</div>
<h2>{L['trade_history']}</h2>
</div>
<div class="chart-label">📊 {L['price_comparison']}</div>
<div class="chart-container" id="price-chart"></div>
<div class="chart-label">📉 {L['relative_spread']}</div>
<div class="chart-container" id="spread-chart"></div>
<div class="trades-wrapper">
<table class="trades-table">
<thead>
<tr>
<th>{'入场时间' if lang == 'zh' else 'Entry'}</th>
<th>{'出场时间' if lang == 'zh' else 'Exit'}</th>
<th>{L['asset']}</th>
<th>{'本金' if lang == 'zh' else 'Cost'}</th>
<th>{'数量' if lang == 'zh' else 'Qty'}</th>
<th>{'入场价' if lang == 'zh' else 'Entry $'}</th>
<th>{'出场价' if lang == 'zh' else 'Exit $'}</th>
<th>{'收益率' if lang == 'zh' else 'Return'}</th>
<th>{L['pnl']}</th>
</tr>
</thead>
<tbody>
{trades_html}
</tbody>
</table>
</div>
</section>
<!-- Performance Metrics Table -->
<section class="section">
<div class="section-header">
<div class="section-icon">📊</div>
<h2>{L['performance_metrics']}</h2>
</div>
<div class="metrics-table-container">
<table class="metrics-table">
<thead>
<tr>
<th colspan="2">{'收益指标' if lang == 'zh' else 'Returns'}</th>
<th colspan="2">{'风险指标' if lang == 'zh' else 'Risk'}</th>
<th colspan="2">{'交易统计' if lang == 'zh' else 'Trading'}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="metric-name">{L['total_return']}</td>
<td class="metric-val {return_class}">{metrics['total_return']:+.2f}%</td>
<td class="metric-name">{L['max_drawdown']}</td>
<td class="metric-val negative">{metrics['max_drawdown']:.2f}%</td>
<td class="metric-name">{L['total_trades']}</td>
<td class="metric-val">{metrics['total_trades']}</td>
</tr>
<tr>
<td class="metric-name">{L['buy_hold_a'].format(a=name_a)}</td>
<td class="metric-val" style="color: var(--accent-btc);">{metrics['buy_hold_return_a']:+.2f}%</td>
<td class="metric-name">{L['sharpe_ratio']}</td>
<td class="metric-val">{metrics['sharpe_ratio']:.2f}</td>
<td class="metric-name">{L['win_rate']}</td>
<td class="metric-val {'positive' if metrics['win_rate'] > 50 else 'negative'}">{metrics['win_rate']:.0f}%</td>
</tr>
<tr>
<td class="metric-name">{L['buy_hold_b'].format(b=name_b)}</td>
<td class="metric-val" style="color: var(--accent-eth);">{metrics['buy_hold_return_b']:+.2f}%</td>
<td class="metric-name">{L['profit_factor']}</td>
<td class="metric-val">{metrics['profit_factor']:.2f}</td>
<td class="metric-name">{L['trades_on_a'].format(a=name_a)}</td>
<td class="metric-val">{metrics['trades_a']}</td>
</tr>
<tr>
<td class="metric-name">{'最终权益' if lang == 'zh' else 'Final Equity'}</td>
<td class="metric-val">${final_equity:,.2f}</td>
<td class="metric-name">{'vs 持有' if lang == 'zh' else 'vs Hold'}</td>
<td class="metric-val {return_class}">{metrics['total_return'] - metrics['buy_hold_return_b']:+.2f}%</td>
<td class="metric-name">{L['trades_on_b'].format(b=name_b)}</td>
<td class="metric-val">{metrics['trades_b']}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Charts Row: Equity + Drawdown -->
<div class="charts-row">
<section class="section chart-half">
<div class="section-header">
<div class="section-icon">💰</div>
<h2>{L['equity_curve']}</h2>
</div>
<div class="chart-container" id="equity-chart"></div>
</section>
<section class="section chart-half">
<div class="section-header">
<div class="section-icon">📉</div>
<h2>{L['max_drawdown']}</h2>
</div>
<div class="chart-container" id="drawdown-chart"></div>
</section>
</div>
<footer class="footer">
<div class="footer-tagline">{L['tagline']}</div>
<a href="https://github.com/0xrikt/crypto-skills" target="_blank" class="footer-github">
<svg height="20" width="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
<span>github.com/0xrikt/crypto-skills</span>
</a>
<div class="footer-note">
{L['generated']} {datetime.now().strftime('%Y-%m-%d %H:%M')} • {L['disclaimer']}
</div>
</footer>
</div>
<script>
const chartTheme = {{
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: {{ color: '#1e293b', family: 'Space Grotesk' }},
xaxis: {{ gridcolor: 'rgba(0,0,0,0.08)', zerolinecolor: 'rgba(0,0,0,0.15)' }},
yaxis: {{ gridcolor: 'rgba(0,0,0,0.08)', zerolinecolor: 'rgba(0,0,0,0.15)' }}
}};
// Spread Chart with Trade Markers
Plotly.newPlot('spread-chart', [
{{
x: {json.dumps(timestamps)},
y: {json.dumps(df['spread'].tolist())},
type: 'scatter',
mode: 'lines',
name: 'Spread',
line: {{ color: '#6366f1', width: 1.5 }},
fill: 'tozeroy',
fillcolor: 'rgba(99, 102, 241, 0.1)'
}},
{{
x: {json.dumps(buy_times)},
y: {json.dumps(buy_spreads)},
type: 'scatter',
mode: 'markers',
name: '{"买入" if lang == "zh" else "Buy"}',
marker: {{ symbol: 'triangle-up', size: 14, color: '#10b981', line: {{ color: '#059669', width: 2 }} }}
}},
{{
x: {json.dumps(sell_times)},
y: {json.dumps(sell_spreads)},
type: 'scatter',
mode: 'markers',
name: '{"卖出" if lang == "zh" else "Sell"}',
marker: {{ symbol: 'triangle-down', size: 14, color: '#f59e0b', line: {{ color: '#d97706', width: 2 }} }}
}}
], {{
...chartTheme,
height: 350,
margin: {{ t: 30, r: 50, b: 50, l: 70 }},
yaxis: {{ ...chartTheme.yaxis, title: '{name_a} vs {name_b} Spread (%)' }},
legend: {{ orientation: 'h', y: 1.1 }},
shapes: [
{{ type: 'line', y0: {config.get('threshold', 3)}, y1: {config.get('threshold', 3)}, x0: 0, x1: 1, xref: 'paper', line: {{ dash: 'dash', color: '#ef4444', width: 1 }} }},
{{ type: 'line', y0: -{config.get('threshold', 3)}, y1: -{config.get('threshold', 3)}, x0: 0, x1: 1, xref: 'paper', line: {{ dash: 'dash', color: '#22c55e', width: 1 }} }},
{{ type: 'line', y0: 0, y1: 0, x0: 0, x1: 1, xref: 'paper', line: {{ color: '#9ca3af', width: 1 }} }}
]
}}, {{ responsive: true }});
// Equity Chart
Plotly.newPlot('equity-chart', [{{
x: {json.dumps(equity_timestamps)},
y: {json.dumps(equities)},
type: 'scatter',
mode: 'lines',
fill: 'tozeroy',
fillcolor: 'rgba(16, 185, 129, 0.15)',
line: {{ color: '#10b981', width: 2 }},
name: 'Portfolio'
}}], {{
...chartTheme,
height: 280,
margin: {{ t: 20, r: 20, b: 40, l: 70 }},
yaxis: {{ ...chartTheme.yaxis, title: 'Equity ($)', tickformat: '$,.0f' }},
shapes: [{{
type: 'line',
y0: {initial_capital}, y1: {initial_capital},
x0: 0, x1: 1, xref: 'paper',
line: {{ dash: 'dash', color: 'rgba(0,0,0,0.3)', width: 1 }}
}}]
}}, {{ responsive: true }});
// Drawdown Chart
Plotly.newPlot('drawdown-chart', [{{
x: {json.dumps(equity_timestamps)},
y: {json.dumps(drawdowns)},
type: 'scatter',
mode: 'lines',
fill: 'tozeroy',
fillcolor: 'rgba(239, 68, 68, 0.15)',
line: {{ color: '#ef4444', width: 1.5 }},
name: 'Drawdown'
}}], {{
...chartTheme,
height: 280,
margin: {{ t: 20, r: 20, b: 40, l: 70 }},
yaxis: {{ ...chartTheme.yaxis, title: 'Drawdown %', tickformat: '.1f' }}
}}, {{ responsive: true }});
// Price Comparison Chart with Trade Markers
Plotly.newPlot('price-chart', [
{{
x: {json.dumps(timestamps)},
y: {json.dumps(df['price_a_norm'].tolist())},
type: 'scatter',
mode: 'lines',
name: '{name_a}',
line: {{ color: '#f7931a', width: 2 }}
}},
{{
x: {json.dumps(timestamps)},
y: {json.dumps(df['price_b_norm'].tolist())},
type: 'scatter',
mode: 'lines',
name: '{name_b}',
line: {{ color: '#627eea', width: 2 }}
}},
{{
x: {json.dumps(buy_times)},
y: {json.dumps(buy_prices_norm)},
type: 'scatter',
mode: 'markers',
name: '{"买入" if lang == "zh" else "Buy"}',
marker: {{ symbol: 'triangle-up', size: 12, color: '#10b981', line: {{ color: '#059669', width: 2 }} }}
}},
{{
x: {json.dumps(sell_times)},
y: {json.dumps(sell_prices_norm)},
type: 'scatter',
mode: 'markers',
name: '{"卖出" if lang == "zh" else "Sell"}',
marker: {{ symbol: 'triangle-down', size: 12, color: '#f59e0b', line: {{ color: '#d97706', width: 2 }} }}
}}
], {{
...chartTheme,
height: 320,
margin: {{ t: 30, r: 50, b: 50, l: 70 }},
yaxis: {{ ...chartTheme.yaxis, title: '{"标准化价格 (基准=100)" if lang == "zh" else "Normalized Price (Base=100)"}' }},
legend: {{ orientation: 'h', y: 1.12 }}
}}, {{ responsive: true }});
</script>
</body>
</html>'''
return html
def main():
parser = argparse.ArgumentParser(description='Pair Trading Backtest')
parser.add_argument('--symbol-a', default='BTC/USDT', help='First symbol (default: BTC/USDT)')
parser.add_argument('--symbol-b', default='ETH/USDT', help='Second symbol (default: ETH/USDT)')
parser.add_argument('--days', type=int, default=365, help='Backtest period in days')
parser.add_argument('--timeframe', default='4h', help='Candle timeframe')
parser.add_argument('--lookback', type=int, default=20, help='Lookback period for spread calculation')
parser.add_argument('--threshold', type=float, default=10.0, help='Entry threshold (spread %)')
parser.add_argument('--exit-threshold', type=float, default=2.0, help='Exit threshold (spread %)')
parser.add_argument('--initial-capital', type=float, default=10000, help='Initial capital')
parser.add_argument('--position-size', type=float, default=20, help='Position size percentage')
parser.add_argument('--stop-loss', type=float, default=10, help='Stop loss percentage')
parser.add_argument('--take-profit', type=float, default=25, help='Take profit percentage')
parser.add_argument('--commission', type=float, default=0.1, help='Commission percentage')
parser.add_argument('--exchange', default='okx', help='Exchange (default: okx). Note: OKX has ~90 day limit, use binance/kucoin for longer backtests')
parser.add_argument('--output', default='pair_trading_report.html', help='Output HTML file')
parser.add_argument('--lang', default='en', choices=['en', 'zh'], help='Report language')
parser.add_argument('--description', default='', help='Original strategy description')
parser.add_argument('--only-long', default='both', choices=['A', 'B', 'both'],
help='Only long specific asset: A, B, or both (default: both)')
args = parser.parse_args()
config = {
'symbol_a': args.symbol_a,
'symbol_b': args.symbol_b,
'days': args.days,
'timeframe': args.timeframe,
'lookback': args.lookback,
'threshold': args.threshold,
'exit_threshold': args.exit_threshold,
'initial_capital': args.initial_capital,
'position_size': args.position_size,
'stop_loss': args.stop_loss,
'take_profit': args.take_profit,
'commission': args.commission,
'description': args.description,
'only_long': args.only_long
}
print(f"\n{'='*60}")
print(f" PAIR TRADING BACKTEST")
print(f" {args.symbol_a} ↔ {args.symbol_b}")
print(f"{'='*60}\n")
# Fetch data
df_a = fetch_data(args.symbol_a, args.days, args.timeframe, args.exchange)
df_b = fetch_data(args.symbol_b, args.days, args.timeframe, args.exchange)
# Calculate spread
print("\n📈 Calculating relative spread...")
df = calculate_spread(df_a, df_b, args.lookback)
print(f" ✓ Spread range: {df['spread'].min():.1f}% to {df['spread'].max():.1f}%")
# Generate signals
print("\n🎯 Generating signals...")
df = generate_signals(df, args.threshold, args.exit_threshold, args.only_long)
signal_count = (df['signal'] != df['signal'].shift()).sum()
only_long_msg = f" (only long {args.only_long})" if args.only_long != 'both' else ""
print(f" ✓ Generated {signal_count} signal changes{only_long_msg}")
# Run backtest
print("\n💰 Running backtest simulation...")
metrics, trades, equity_df = run_backtest(df, config)
print(f"\n{'='*60}")
print(f" RESULTS")
print(f"{'='*60}")
print(f" Total Return: {metrics['total_return']:+.1f}%")
print(f" Buy & Hold {args.symbol_a.split('/')[0]}: {metrics['buy_hold_return_a']:+.1f}%")
print(f" Buy & Hold {args.symbol_b.split('/')[0]}: {metrics['buy_hold_return_b']:+.1f}%")
print(f" Max Drawdown: {metrics['max_drawdown']:.1f}%")
print(f" Sharpe Ratio: {metrics['sharpe_ratio']:.2f}")
print(f" Win Rate: {metrics['win_rate']:.0f}%")
print(f" Total Trades: {metrics['total_trades']}")
print(f"{'='*60}\n")
# Generate report
print("📄 Generating HTML report...")
html = generate_html_report(df, metrics, trades, equity_df, config, args.lang)
with open(args.output, 'w', encoding='utf-8') as f:
f.write(html)
print(f" ✓ Report saved to: {args.output}")
print(f"\n✅ Done!\n")
if __name__ == '__main__':
main()
Related skills
FAQ
Does crypto-backtest support leverage or shorting?
No. It supports spot trading strategies only, all long-only, with no leverage, shorting, or futures/perpetual contracts.
Where does the historical data come from?
It fetches real historical data from 200+ exchanges via CCXT, with a note that OKX only provides roughly 60-90 days of history.