|
|
|
|
|
"""
|
|
|
Backtesting Service
|
|
|
===================
|
|
|
سرویس بکتست برای ارزیابی استراتژیهای معاملاتی با دادههای تاریخی
|
|
|
"""
|
|
|
|
|
|
from typing import Optional, List, Dict, Any, Tuple
|
|
|
from datetime import datetime, timedelta
|
|
|
from sqlalchemy.orm import Session
|
|
|
from sqlalchemy import and_, desc
|
|
|
import uuid
|
|
|
import logging
|
|
|
import json
|
|
|
import math
|
|
|
|
|
|
from database.models import (
|
|
|
Base, BacktestJob, TrainingStatus, CachedOHLC
|
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
class BacktestingService:
|
|
|
"""سرویس اصلی بکتست"""
|
|
|
|
|
|
def __init__(self, db_session: Session):
|
|
|
"""
|
|
|
Initialize the backtesting service.
|
|
|
|
|
|
Args:
|
|
|
db_session: SQLAlchemy database session
|
|
|
"""
|
|
|
self.db = db_session
|
|
|
|
|
|
def start_backtest(
|
|
|
self,
|
|
|
strategy: str,
|
|
|
symbol: str,
|
|
|
start_date: datetime,
|
|
|
end_date: datetime,
|
|
|
initial_capital: float
|
|
|
) -> Dict[str, Any]:
|
|
|
"""
|
|
|
Start a backtest for a specific strategy.
|
|
|
|
|
|
Args:
|
|
|
strategy: Name of the strategy to backtest
|
|
|
symbol: Trading pair (e.g., "BTC/USDT")
|
|
|
start_date: Backtest start date
|
|
|
end_date: Backtest end date
|
|
|
initial_capital: Starting capital
|
|
|
|
|
|
Returns:
|
|
|
Dict containing backtest job details
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
job_id = f"BT-{uuid.uuid4().hex[:12].upper()}"
|
|
|
|
|
|
|
|
|
job = BacktestJob(
|
|
|
job_id=job_id,
|
|
|
strategy=strategy,
|
|
|
symbol=symbol.upper(),
|
|
|
start_date=start_date,
|
|
|
end_date=end_date,
|
|
|
initial_capital=initial_capital,
|
|
|
status=TrainingStatus.PENDING
|
|
|
)
|
|
|
|
|
|
self.db.add(job)
|
|
|
self.db.commit()
|
|
|
self.db.refresh(job)
|
|
|
|
|
|
|
|
|
results = self._run_backtest(job)
|
|
|
|
|
|
|
|
|
job.status = TrainingStatus.COMPLETED
|
|
|
job.total_return = results["total_return"]
|
|
|
job.sharpe_ratio = results["sharpe_ratio"]
|
|
|
job.max_drawdown = results["max_drawdown"]
|
|
|
job.win_rate = results["win_rate"]
|
|
|
job.total_trades = results["total_trades"]
|
|
|
job.results = json.dumps(results)
|
|
|
job.completed_at = datetime.utcnow()
|
|
|
|
|
|
self.db.commit()
|
|
|
self.db.refresh(job)
|
|
|
|
|
|
logger.info(f"Backtest {job_id} completed successfully")
|
|
|
|
|
|
return self._job_to_dict(job)
|
|
|
|
|
|
except Exception as e:
|
|
|
self.db.rollback()
|
|
|
logger.error(f"Error starting backtest: {e}", exc_info=True)
|
|
|
raise
|
|
|
|
|
|
def _run_backtest(self, job: BacktestJob) -> Dict[str, Any]:
|
|
|
"""
|
|
|
Execute the backtest logic.
|
|
|
|
|
|
Args:
|
|
|
job: Backtest job
|
|
|
|
|
|
Returns:
|
|
|
Dict containing backtest results
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
historical_data = self._fetch_historical_data(
|
|
|
job.symbol,
|
|
|
job.start_date,
|
|
|
job.end_date
|
|
|
)
|
|
|
|
|
|
if not historical_data:
|
|
|
raise ValueError(f"No historical data found for {job.symbol}")
|
|
|
|
|
|
|
|
|
strategy_func = self._get_strategy_function(job.strategy)
|
|
|
|
|
|
|
|
|
capital = job.initial_capital
|
|
|
position = 0.0
|
|
|
entry_price = 0.0
|
|
|
trades = []
|
|
|
equity_curve = [capital]
|
|
|
high_water_mark = capital
|
|
|
max_drawdown = 0.0
|
|
|
|
|
|
|
|
|
for i, candle in enumerate(historical_data):
|
|
|
close_price = candle["close"]
|
|
|
signal = strategy_func(historical_data[:i+1], close_price)
|
|
|
|
|
|
|
|
|
if signal == "BUY" and position == 0:
|
|
|
|
|
|
position = capital / close_price
|
|
|
entry_price = close_price
|
|
|
capital = 0
|
|
|
|
|
|
elif signal == "SELL" and position > 0:
|
|
|
|
|
|
capital = position * close_price
|
|
|
pnl = capital - (position * entry_price)
|
|
|
trades.append({
|
|
|
"entry_price": entry_price,
|
|
|
"exit_price": close_price,
|
|
|
"pnl": pnl,
|
|
|
"return_pct": (pnl / (position * entry_price)) * 100,
|
|
|
"timestamp": candle["timestamp"]
|
|
|
})
|
|
|
position = 0
|
|
|
entry_price = 0.0
|
|
|
|
|
|
|
|
|
current_equity = capital + (position * close_price if position > 0 else 0)
|
|
|
equity_curve.append(current_equity)
|
|
|
|
|
|
|
|
|
if current_equity > high_water_mark:
|
|
|
high_water_mark = current_equity
|
|
|
|
|
|
drawdown = ((high_water_mark - current_equity) / high_water_mark) * 100
|
|
|
if drawdown > max_drawdown:
|
|
|
max_drawdown = drawdown
|
|
|
|
|
|
|
|
|
if position > 0:
|
|
|
final_price = historical_data[-1]["close"]
|
|
|
capital = position * final_price
|
|
|
pnl = capital - (position * entry_price)
|
|
|
trades.append({
|
|
|
"entry_price": entry_price,
|
|
|
"exit_price": final_price,
|
|
|
"pnl": pnl,
|
|
|
"return_pct": (pnl / (position * entry_price)) * 100,
|
|
|
"timestamp": historical_data[-1]["timestamp"]
|
|
|
})
|
|
|
|
|
|
|
|
|
total_return = ((capital - job.initial_capital) / job.initial_capital) * 100
|
|
|
win_rate = self._calculate_win_rate(trades)
|
|
|
sharpe_ratio = self._calculate_sharpe_ratio(equity_curve)
|
|
|
|
|
|
return {
|
|
|
"total_return": total_return,
|
|
|
"sharpe_ratio": sharpe_ratio,
|
|
|
"max_drawdown": max_drawdown,
|
|
|
"win_rate": win_rate,
|
|
|
"total_trades": len(trades),
|
|
|
"trades": trades,
|
|
|
"equity_curve": equity_curve[-100:]
|
|
|
}
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error running backtest: {e}", exc_info=True)
|
|
|
raise
|
|
|
|
|
|
def _fetch_historical_data(
|
|
|
self,
|
|
|
symbol: str,
|
|
|
start_date: datetime,
|
|
|
end_date: datetime
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
"""
|
|
|
Fetch historical OHLC data.
|
|
|
|
|
|
Args:
|
|
|
symbol: Trading pair
|
|
|
start_date: Start date
|
|
|
end_date: End date
|
|
|
|
|
|
Returns:
|
|
|
List of candle dictionaries
|
|
|
"""
|
|
|
try:
|
|
|
|
|
|
db_symbol = symbol.replace("/", "").upper()
|
|
|
|
|
|
candles = self.db.query(CachedOHLC).filter(
|
|
|
and_(
|
|
|
CachedOHLC.symbol == db_symbol,
|
|
|
CachedOHLC.timestamp >= start_date,
|
|
|
CachedOHLC.timestamp <= end_date,
|
|
|
CachedOHLC.interval == "1h"
|
|
|
)
|
|
|
).order_by(CachedOHLC.timestamp.asc()).all()
|
|
|
|
|
|
return [
|
|
|
{
|
|
|
"timestamp": c.timestamp.isoformat() if c.timestamp else None,
|
|
|
"open": c.open,
|
|
|
"high": c.high,
|
|
|
"low": c.low,
|
|
|
"close": c.close,
|
|
|
"volume": c.volume
|
|
|
}
|
|
|
for c in candles
|
|
|
]
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error fetching historical data: {e}", exc_info=True)
|
|
|
return []
|
|
|
|
|
|
def _get_strategy_function(self, strategy_name: str):
|
|
|
"""
|
|
|
Get strategy function by name.
|
|
|
|
|
|
Args:
|
|
|
strategy_name: Strategy name
|
|
|
|
|
|
Returns:
|
|
|
Strategy function
|
|
|
"""
|
|
|
strategies = {
|
|
|
"simple_moving_average": self._sma_strategy,
|
|
|
"rsi_strategy": self._rsi_strategy,
|
|
|
"macd_strategy": self._macd_strategy
|
|
|
}
|
|
|
|
|
|
return strategies.get(strategy_name, self._sma_strategy)
|
|
|
|
|
|
def _sma_strategy(self, data: List[Dict], current_price: float) -> str:
|
|
|
"""Simple Moving Average strategy."""
|
|
|
if len(data) < 50:
|
|
|
return "HOLD"
|
|
|
|
|
|
|
|
|
closes = [d["close"] for d in data[-50:]]
|
|
|
sma_short = sum(closes[-10:]) / 10
|
|
|
sma_long = sum(closes) / 50
|
|
|
|
|
|
if sma_short > sma_long:
|
|
|
return "BUY"
|
|
|
elif sma_short < sma_long:
|
|
|
return "SELL"
|
|
|
return "HOLD"
|
|
|
|
|
|
def _rsi_strategy(self, data: List[Dict], current_price: float) -> str:
|
|
|
"""RSI strategy."""
|
|
|
if len(data) < 14:
|
|
|
return "HOLD"
|
|
|
|
|
|
|
|
|
closes = [d["close"] for d in data[-14:]]
|
|
|
gains = [max(0, closes[i] - closes[i-1]) for i in range(1, len(closes))]
|
|
|
losses = [max(0, closes[i-1] - closes[i]) for i in range(1, len(closes))]
|
|
|
|
|
|
avg_gain = sum(gains) / len(gains) if gains else 0
|
|
|
avg_loss = sum(losses) / len(losses) if losses else 0
|
|
|
|
|
|
if avg_loss == 0:
|
|
|
rsi = 100
|
|
|
else:
|
|
|
rs = avg_gain / avg_loss
|
|
|
rsi = 100 - (100 / (1 + rs))
|
|
|
|
|
|
if rsi < 30:
|
|
|
return "BUY"
|
|
|
elif rsi > 70:
|
|
|
return "SELL"
|
|
|
return "HOLD"
|
|
|
|
|
|
def _macd_strategy(self, data: List[Dict], current_price: float) -> str:
|
|
|
"""MACD strategy."""
|
|
|
if len(data) < 26:
|
|
|
return "HOLD"
|
|
|
|
|
|
|
|
|
closes = [d["close"] for d in data[-26:]]
|
|
|
ema_12 = sum(closes[-12:]) / 12
|
|
|
ema_26 = sum(closes) / 26
|
|
|
|
|
|
macd = ema_12 - ema_26
|
|
|
|
|
|
if macd > 0:
|
|
|
return "BUY"
|
|
|
elif macd < 0:
|
|
|
return "SELL"
|
|
|
return "HOLD"
|
|
|
|
|
|
def _calculate_win_rate(self, trades: List[Dict]) -> float:
|
|
|
"""Calculate win rate from trades."""
|
|
|
if not trades:
|
|
|
return 0.0
|
|
|
|
|
|
winning_trades = sum(1 for t in trades if t["pnl"] > 0)
|
|
|
return (winning_trades / len(trades)) * 100
|
|
|
|
|
|
def _calculate_sharpe_ratio(self, equity_curve: List[float]) -> float:
|
|
|
"""Calculate Sharpe ratio from equity curve."""
|
|
|
if len(equity_curve) < 2:
|
|
|
return 0.0
|
|
|
|
|
|
returns = []
|
|
|
for i in range(1, len(equity_curve)):
|
|
|
if equity_curve[i-1] > 0:
|
|
|
ret = (equity_curve[i] - equity_curve[i-1]) / equity_curve[i-1]
|
|
|
returns.append(ret)
|
|
|
|
|
|
if not returns:
|
|
|
return 0.0
|
|
|
|
|
|
mean_return = sum(returns) / len(returns)
|
|
|
variance = sum((r - mean_return) ** 2 for r in returns) / len(returns)
|
|
|
std_dev = math.sqrt(variance) if variance > 0 else 0.0001
|
|
|
|
|
|
|
|
|
sharpe = (mean_return / std_dev) * math.sqrt(365) if std_dev > 0 else 0.0
|
|
|
|
|
|
return sharpe
|
|
|
|
|
|
def _job_to_dict(self, job: BacktestJob) -> Dict[str, Any]:
|
|
|
"""Convert job model to dictionary."""
|
|
|
results = json.loads(job.results) if job.results else {}
|
|
|
|
|
|
return {
|
|
|
"job_id": job.job_id,
|
|
|
"strategy": job.strategy,
|
|
|
"symbol": job.symbol,
|
|
|
"start_date": job.start_date.isoformat() if job.start_date else None,
|
|
|
"end_date": job.end_date.isoformat() if job.end_date else None,
|
|
|
"initial_capital": job.initial_capital,
|
|
|
"status": job.status.value if job.status else None,
|
|
|
"total_return": job.total_return,
|
|
|
"sharpe_ratio": job.sharpe_ratio,
|
|
|
"max_drawdown": job.max_drawdown,
|
|
|
"win_rate": job.win_rate,
|
|
|
"total_trades": job.total_trades,
|
|
|
"results": results,
|
|
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
|
|
"completed_at": job.completed_at.isoformat() if job.completed_at else None
|
|
|
}
|
|
|
|
|
|
|