deploy: 2026-03-20 07:49
This commit is contained in:
0
backtest/__init__.py
Normal file
0
backtest/__init__.py
Normal file
362
backtest/backtester.py
Normal file
362
backtest/backtester.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Backtesting engine for ICT strategies.
|
||||
|
||||
Simulates trading on historical data with realistic fees
|
||||
and slippage to evaluate strategy performance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
|
||||
from config import settings
|
||||
from indicators.ict_engine import ICTEngine
|
||||
from indicators.multi_timeframe import MarketBias, TradeDirection
|
||||
from indicators.confluence import ConfluenceChecker
|
||||
from strategy.entry_rules import EntryRules
|
||||
from strategy.exit_rules import ExitRules, ExitReason
|
||||
from risk.risk_manager import RiskManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestTrade:
|
||||
"""A single trade in a backtest."""
|
||||
|
||||
entry_index: int
|
||||
exit_index: int
|
||||
direction: TradeDirection
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
amount: float
|
||||
pnl: float
|
||||
fee: float
|
||||
entry_reason: str = ""
|
||||
exit_reason: str = ""
|
||||
confluence_score: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
"""Complete backtest outcome."""
|
||||
|
||||
trades: List[BacktestTrade] = field(default_factory=list)
|
||||
equity_curve: List[float] = field(default_factory=list)
|
||||
total_pnl: float = 0.0
|
||||
roi_percent: float = 0.0
|
||||
win_rate: float = 0.0
|
||||
profit_factor: float = 0.0
|
||||
sharpe_ratio: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
total_trades: int = 0
|
||||
winning_trades: int = 0
|
||||
losing_trades: int = 0
|
||||
avg_trade_duration: float = 0.0 # in candles
|
||||
initial_balance: float = 0.0
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"total_trades": self.total_trades,
|
||||
"winning_trades": self.winning_trades,
|
||||
"losing_trades": self.losing_trades,
|
||||
"win_rate": f"{self.win_rate:.1%}",
|
||||
"total_pnl": f"{self.total_pnl:.2f}",
|
||||
"roi": f"{self.roi_percent:.2%}",
|
||||
"profit_factor": f"{self.profit_factor:.2f}",
|
||||
"sharpe_ratio": f"{self.sharpe_ratio:.2f}",
|
||||
"max_drawdown": f"{self.max_drawdown:.2%}",
|
||||
"avg_duration_candles": f"{self.avg_trade_duration:.1f}",
|
||||
}
|
||||
|
||||
|
||||
class Backtester:
|
||||
"""ICT strategy backtesting engine.
|
||||
|
||||
Iterates over historical OHLCV data candle by candle,
|
||||
applying entry/exit rules and tracking performance.
|
||||
"""
|
||||
|
||||
FEE_RATE = 0.001 # 0.1% taker fee
|
||||
SLIPPAGE_RATE = 0.0005 # 0.05% slippage
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ict_engine: ICTEngine | None = None,
|
||||
entry_rules: EntryRules | None = None,
|
||||
exit_rules: ExitRules | None = None,
|
||||
risk_manager: RiskManager | None = None,
|
||||
):
|
||||
self.engine = ict_engine or ICTEngine()
|
||||
self.entry_rules = entry_rules or EntryRules()
|
||||
self.exit_rules = exit_rules or ExitRules()
|
||||
self.risk = risk_manager or RiskManager()
|
||||
|
||||
def run(
|
||||
self,
|
||||
ohlc: pd.DataFrame,
|
||||
initial_balance: float = 1000.0,
|
||||
risk_per_trade: float = 0.02,
|
||||
) -> BacktestResult:
|
||||
"""Execute backtest on the provided OHLC data.
|
||||
|
||||
Args:
|
||||
ohlc: DataFrame with [open, high, low, close, volume].
|
||||
initial_balance: Starting capital in quote currency.
|
||||
risk_per_trade: Fraction of balance to risk per trade.
|
||||
|
||||
Returns:
|
||||
BacktestResult with all metrics.
|
||||
"""
|
||||
balance = initial_balance
|
||||
equity_curve = [balance]
|
||||
trades: List[BacktestTrade] = []
|
||||
min_bars = max(self.engine.swing_length + 10, 60)
|
||||
|
||||
# Active position state
|
||||
in_position = False
|
||||
pos_direction: Optional[TradeDirection] = None
|
||||
pos_entry_price = 0.0
|
||||
pos_amount = 0.0
|
||||
pos_sl = 0.0
|
||||
pos_tp = 0.0
|
||||
pos_entry_idx = 0
|
||||
pos_trailing: Optional[float] = None
|
||||
pos_confluence = 0
|
||||
pos_candles = 0
|
||||
|
||||
logger.info(
|
||||
"Backtest starting: {} candles, balance={}", len(ohlc), initial_balance
|
||||
)
|
||||
|
||||
for i in range(min_bars, len(ohlc)):
|
||||
window = ohlc.iloc[:i + 1]
|
||||
current_price = float(window["close"].iloc[-1])
|
||||
high = float(window["high"].iloc[-1])
|
||||
low = float(window["low"].iloc[-1])
|
||||
|
||||
if in_position:
|
||||
pos_candles += 1
|
||||
|
||||
# Check exit using high/low for SL/TP accuracy
|
||||
exit_price = current_price
|
||||
exit_reason: Optional[ExitReason] = None
|
||||
|
||||
if pos_direction == TradeDirection.LONG:
|
||||
if low <= pos_sl:
|
||||
exit_price = pos_sl
|
||||
exit_reason = ExitReason.STOP_LOSS
|
||||
elif high >= pos_tp:
|
||||
exit_price = pos_tp
|
||||
exit_reason = ExitReason.TAKE_PROFIT
|
||||
else:
|
||||
if high >= pos_sl:
|
||||
exit_price = pos_sl
|
||||
exit_reason = ExitReason.STOP_LOSS
|
||||
elif low <= pos_tp:
|
||||
exit_price = pos_tp
|
||||
exit_reason = ExitReason.TAKE_PROFIT
|
||||
|
||||
# Update trailing
|
||||
if exit_reason is None:
|
||||
pos_trailing = self.exit_rules.update_trailing_stop(
|
||||
pos_direction, pos_entry_price, current_price, pos_trailing
|
||||
)
|
||||
if pos_trailing is not None:
|
||||
if pos_direction == TradeDirection.LONG and low <= pos_trailing:
|
||||
exit_price = pos_trailing
|
||||
exit_reason = ExitReason.TRAILING_STOP
|
||||
elif pos_direction == TradeDirection.SHORT and high >= pos_trailing:
|
||||
exit_price = pos_trailing
|
||||
exit_reason = ExitReason.TRAILING_STOP
|
||||
|
||||
# Time exit
|
||||
if exit_reason is None and pos_candles >= self.exit_rules.time_exit_candles:
|
||||
exit_price = current_price
|
||||
exit_reason = ExitReason.TIME_EXIT
|
||||
|
||||
# CHOCH check
|
||||
if exit_reason is None:
|
||||
try:
|
||||
signals = self.engine.analyze(window)
|
||||
choch = signals.latest_choch
|
||||
if choch is not None:
|
||||
if pos_direction == TradeDirection.LONG and choch == -1:
|
||||
exit_price = current_price
|
||||
exit_reason = ExitReason.CHOCH
|
||||
elif pos_direction == TradeDirection.SHORT and choch == 1:
|
||||
exit_price = current_price
|
||||
exit_reason = ExitReason.CHOCH
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if exit_reason is not None:
|
||||
# Apply slippage
|
||||
if pos_direction == TradeDirection.LONG:
|
||||
exit_price *= (1 - self.SLIPPAGE_RATE)
|
||||
pnl = (exit_price - pos_entry_price) * pos_amount
|
||||
else:
|
||||
exit_price *= (1 + self.SLIPPAGE_RATE)
|
||||
pnl = (pos_entry_price - exit_price) * pos_amount
|
||||
|
||||
fee = exit_price * pos_amount * self.FEE_RATE
|
||||
pnl -= fee
|
||||
|
||||
balance += pnl
|
||||
trades.append(
|
||||
BacktestTrade(
|
||||
entry_index=pos_entry_idx,
|
||||
exit_index=i,
|
||||
direction=pos_direction,
|
||||
entry_price=pos_entry_price,
|
||||
exit_price=exit_price,
|
||||
amount=pos_amount,
|
||||
pnl=pnl,
|
||||
fee=fee,
|
||||
exit_reason=exit_reason.value,
|
||||
confluence_score=pos_confluence,
|
||||
)
|
||||
)
|
||||
in_position = False
|
||||
pos_trailing = None
|
||||
pos_candles = 0
|
||||
|
||||
else:
|
||||
# Look for entry
|
||||
try:
|
||||
signals = self.engine.analyze(window)
|
||||
except Exception:
|
||||
equity_curve.append(balance)
|
||||
continue
|
||||
|
||||
# Simple bias from BOS
|
||||
bos = signals.latest_bos
|
||||
if bos is None:
|
||||
equity_curve.append(balance)
|
||||
continue
|
||||
|
||||
if bos == 1:
|
||||
entry_result = self.entry_rules.check_bullish_entry(signals, current_price)
|
||||
direction = TradeDirection.LONG
|
||||
elif bos == -1:
|
||||
entry_result = self.entry_rules.check_bearish_entry(signals, current_price)
|
||||
direction = TradeDirection.SHORT
|
||||
else:
|
||||
equity_curve.append(balance)
|
||||
continue
|
||||
|
||||
if entry_result.is_valid:
|
||||
sl = self.entry_rules.calculate_stop_loss(direction, signals, current_price)
|
||||
tp = self.entry_rules.calculate_take_profit(direction, signals, current_price, sl)
|
||||
|
||||
price_risk = abs(current_price - sl)
|
||||
if price_risk == 0:
|
||||
equity_curve.append(balance)
|
||||
continue
|
||||
|
||||
pos_amount = (balance * risk_per_trade) / price_risk
|
||||
entry_fee = current_price * pos_amount * self.FEE_RATE
|
||||
entry_price = current_price * (
|
||||
(1 + self.SLIPPAGE_RATE) if direction == TradeDirection.LONG
|
||||
else (1 - self.SLIPPAGE_RATE)
|
||||
)
|
||||
|
||||
balance -= entry_fee
|
||||
|
||||
in_position = True
|
||||
pos_direction = direction
|
||||
pos_entry_price = entry_price
|
||||
pos_sl = sl
|
||||
pos_tp = tp
|
||||
pos_entry_idx = i
|
||||
pos_confluence = len(entry_result.conditions_met)
|
||||
|
||||
equity_curve.append(balance)
|
||||
|
||||
result = self._calculate_metrics(trades, equity_curve, initial_balance)
|
||||
logger.info("Backtest complete: {}", result.summary())
|
||||
return result
|
||||
|
||||
def _calculate_metrics(
|
||||
self,
|
||||
trades: List[BacktestTrade],
|
||||
equity_curve: List[float],
|
||||
initial_balance: float,
|
||||
) -> BacktestResult:
|
||||
"""Compute performance metrics from trade list and equity curve."""
|
||||
total = len(trades)
|
||||
wins = [t for t in trades if t.pnl > 0]
|
||||
losses = [t for t in trades if t.pnl <= 0]
|
||||
|
||||
total_pnl = sum(t.pnl for t in trades)
|
||||
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
|
||||
|
||||
# Max drawdown
|
||||
peak = initial_balance
|
||||
max_dd = 0.0
|
||||
for eq in equity_curve:
|
||||
if eq > peak:
|
||||
peak = eq
|
||||
dd = (peak - eq) / peak if peak > 0 else 0
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
# Sharpe ratio (simplified, daily returns)
|
||||
if len(equity_curve) > 1:
|
||||
returns = pd.Series(equity_curve).pct_change().dropna()
|
||||
if returns.std() > 0:
|
||||
sharpe = (returns.mean() / returns.std()) * (252 ** 0.5)
|
||||
else:
|
||||
sharpe = 0.0
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
# Average duration
|
||||
avg_dur = (
|
||||
sum(t.exit_index - t.entry_index for t in trades) / total
|
||||
if total > 0 else 0
|
||||
)
|
||||
|
||||
return BacktestResult(
|
||||
trades=trades,
|
||||
equity_curve=equity_curve,
|
||||
total_pnl=total_pnl,
|
||||
roi_percent=total_pnl / initial_balance if initial_balance > 0 else 0,
|
||||
win_rate=len(wins) / total if total > 0 else 0,
|
||||
profit_factor=gross_profit / gross_loss if gross_loss > 0 else float("inf"),
|
||||
sharpe_ratio=sharpe,
|
||||
max_drawdown=max_dd,
|
||||
total_trades=total,
|
||||
winning_trades=len(wins),
|
||||
losing_trades=len(losses),
|
||||
avg_trade_duration=avg_dur,
|
||||
initial_balance=initial_balance,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_report(result: BacktestResult) -> str:
|
||||
"""Generate a human-readable backtest report."""
|
||||
lines = [
|
||||
"=" * 50,
|
||||
" ICT Strategy Backtest Report",
|
||||
"=" * 50,
|
||||
f" Initial Balance : ${result.initial_balance:,.2f}",
|
||||
f" Final Balance : ${result.initial_balance + result.total_pnl:,.2f}",
|
||||
f" Total PnL : ${result.total_pnl:,.2f}",
|
||||
f" ROI : {result.roi_percent:.2%}",
|
||||
"-" * 50,
|
||||
f" Total Trades : {result.total_trades}",
|
||||
f" Winning : {result.winning_trades}",
|
||||
f" Losing : {result.losing_trades}",
|
||||
f" Win Rate : {result.win_rate:.1%}",
|
||||
f" Profit Factor : {result.profit_factor:.2f}",
|
||||
"-" * 50,
|
||||
f" Sharpe Ratio : {result.sharpe_ratio:.2f}",
|
||||
f" Max Drawdown : {result.max_drawdown:.2%}",
|
||||
f" Avg Duration : {result.avg_trade_duration:.1f} candles",
|
||||
"=" * 50,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
114
backtest/data_loader.py
Normal file
114
backtest/data_loader.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Historical data loader for backtesting.
|
||||
|
||||
Fetches OHLCV data from exchanges or loads from CSV files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
|
||||
from execution.exchange_client import ExchangeClient
|
||||
|
||||
|
||||
class DataLoader:
|
||||
"""Load historical OHLCV data for backtesting."""
|
||||
|
||||
def __init__(self, exchange_client: ExchangeClient | None = None):
|
||||
self._client = exchange_client
|
||||
|
||||
async def fetch_from_exchange(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str = "1h",
|
||||
since: str | None = None,
|
||||
limit: int = 1000,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetch historical data from an exchange.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair (e.g., "BTC/USDT").
|
||||
timeframe: Candle timeframe.
|
||||
since: ISO date string for start (e.g., "2025-01-01").
|
||||
limit: Max candles to fetch.
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("ExchangeClient required for live data fetch")
|
||||
|
||||
if not await self._client.is_connected():
|
||||
await self._client.connect()
|
||||
|
||||
since_ts = None
|
||||
if since:
|
||||
since_ts = int(pd.Timestamp(since).timestamp() * 1000)
|
||||
|
||||
# Fetch in chunks if needed
|
||||
all_data: list = []
|
||||
remaining = limit
|
||||
current_since = since_ts
|
||||
|
||||
while remaining > 0:
|
||||
batch_limit = min(remaining, 500)
|
||||
df = await self._client.fetch_ohlcv(
|
||||
symbol, timeframe, since=current_since, limit=batch_limit
|
||||
)
|
||||
if df.empty:
|
||||
break
|
||||
all_data.append(df)
|
||||
remaining -= len(df)
|
||||
# Move since to after the last candle
|
||||
current_since = int(df.index[-1].timestamp() * 1000) + 1
|
||||
|
||||
if len(df) < batch_limit:
|
||||
break
|
||||
|
||||
if not all_data:
|
||||
return pd.DataFrame()
|
||||
|
||||
result = pd.concat(all_data)
|
||||
result = result[~result.index.duplicated(keep="last")]
|
||||
logger.info("Loaded {} candles for {} {}", len(result), symbol, timeframe)
|
||||
return result.sort_index()
|
||||
|
||||
@staticmethod
|
||||
def load_from_csv(file_path: str) -> pd.DataFrame:
|
||||
"""Load OHLCV data from a CSV file.
|
||||
|
||||
Expected columns: timestamp (or date), open, high, low, close, volume
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"CSV file not found: {file_path}")
|
||||
|
||||
df = pd.read_csv(file_path)
|
||||
|
||||
# Detect timestamp column
|
||||
ts_col = None
|
||||
for col in ["timestamp", "date", "datetime", "time"]:
|
||||
if col in df.columns:
|
||||
ts_col = col
|
||||
break
|
||||
|
||||
if ts_col:
|
||||
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||
df.set_index(ts_col, inplace=True)
|
||||
|
||||
required = {"open", "high", "low", "close", "volume"}
|
||||
missing = required - set(df.columns)
|
||||
if missing:
|
||||
raise ValueError(f"CSV missing required columns: {missing}")
|
||||
|
||||
logger.info("Loaded {} candles from {}", len(df), file_path)
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def save_to_csv(df: pd.DataFrame, file_path: str) -> None:
|
||||
"""Save OHLCV DataFrame to CSV."""
|
||||
path = Path(file_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_csv(file_path)
|
||||
logger.info("Saved {} candles to {}", len(df), file_path)
|
||||
85
backtest/performance.py
Normal file
85
backtest/performance.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Performance analysis and reporting utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.backtester import BacktestResult, BacktestTrade
|
||||
|
||||
|
||||
def compute_sharpe_ratio(
|
||||
equity_curve: List[float], risk_free_rate: float = 0.0, periods: int = 252
|
||||
) -> float:
|
||||
"""Compute annualised Sharpe ratio from an equity curve."""
|
||||
if len(equity_curve) < 2:
|
||||
return 0.0
|
||||
returns = pd.Series(equity_curve).pct_change().dropna()
|
||||
excess = returns - risk_free_rate / periods
|
||||
if excess.std() == 0:
|
||||
return 0.0
|
||||
return float((excess.mean() / excess.std()) * np.sqrt(periods))
|
||||
|
||||
|
||||
def compute_sortino_ratio(
|
||||
equity_curve: List[float], risk_free_rate: float = 0.0, periods: int = 252
|
||||
) -> float:
|
||||
"""Compute annualised Sortino ratio (downside deviation only)."""
|
||||
if len(equity_curve) < 2:
|
||||
return 0.0
|
||||
returns = pd.Series(equity_curve).pct_change().dropna()
|
||||
excess = returns - risk_free_rate / periods
|
||||
downside = excess[excess < 0]
|
||||
if len(downside) == 0 or downside.std() == 0:
|
||||
return float("inf") if excess.mean() > 0 else 0.0
|
||||
return float((excess.mean() / downside.std()) * np.sqrt(periods))
|
||||
|
||||
|
||||
def compute_max_consecutive_losses(trades: List[BacktestTrade]) -> int:
|
||||
"""Return the longest streak of consecutive losing trades."""
|
||||
max_streak = 0
|
||||
current = 0
|
||||
for t in trades:
|
||||
if t.pnl <= 0:
|
||||
current += 1
|
||||
max_streak = max(max_streak, current)
|
||||
else:
|
||||
current = 0
|
||||
return max_streak
|
||||
|
||||
|
||||
def compute_calmar_ratio(
|
||||
total_return: float, max_drawdown: float, years: float = 1.0
|
||||
) -> float:
|
||||
"""Calmar ratio = annualised return / max drawdown."""
|
||||
if max_drawdown == 0:
|
||||
return float("inf") if total_return > 0 else 0.0
|
||||
annual_return = total_return / years
|
||||
return annual_return / max_drawdown
|
||||
|
||||
|
||||
def trade_analysis(trades: List[BacktestTrade]) -> dict:
|
||||
"""Detailed trade-level statistics."""
|
||||
if not trades:
|
||||
return {}
|
||||
|
||||
pnls = [t.pnl for t in trades]
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p <= 0]
|
||||
durations = [t.exit_index - t.entry_index for t in trades]
|
||||
|
||||
return {
|
||||
"total_trades": len(trades),
|
||||
"avg_pnl": np.mean(pnls),
|
||||
"median_pnl": np.median(pnls),
|
||||
"std_pnl": np.std(pnls),
|
||||
"best_trade": max(pnls),
|
||||
"worst_trade": min(pnls),
|
||||
"avg_win": np.mean(wins) if wins else 0,
|
||||
"avg_loss": np.mean(losses) if losses else 0,
|
||||
"max_consecutive_losses": compute_max_consecutive_losses(trades),
|
||||
"avg_duration_candles": np.mean(durations),
|
||||
"total_fees": sum(t.fee for t in trades),
|
||||
}
|
||||
Reference in New Issue
Block a user