|
|
|
|
|
""" |
|
|
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 |
|
|
} |
|
|
|
|
|
|