- Added FastAPI backend with core API endpoints for strategies, backtests, and data management. - Introduced Vue 3 frontend with a dark theme, enabling users to run backtests, adjust parameters, and compare results. - Implemented Pydantic schemas for request/response validation and SQLAlchemy models for database interactions. - Enhanced project structure with dedicated modules for services, routers, and components. - Updated dependencies in `pyproject.toml` and `frontend/package.json` to include FastAPI, SQLAlchemy, and Vue-related packages. - Improved `.gitignore` to exclude unnecessary files and directories.
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""
|
|
Backtest runner service that wraps the existing Backtester engine.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import pandas as pd
|
|
|
|
from api.models.schemas import (
|
|
BacktestMetrics,
|
|
BacktestRequest,
|
|
BacktestResult,
|
|
EquityPoint,
|
|
TradeRecord,
|
|
)
|
|
from engine.backtester import Backtester
|
|
from engine.data_manager import DataManager
|
|
from engine.logging_config import get_logger
|
|
from engine.market import MarketType
|
|
from strategies.factory import get_strategy
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class BacktestRunner:
|
|
"""
|
|
Service for executing backtests via the API.
|
|
|
|
Wraps the existing Backtester engine and converts results
|
|
to API response format.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.dm = DataManager()
|
|
self.bt = Backtester(self.dm)
|
|
|
|
def run(self, request: BacktestRequest) -> BacktestResult:
|
|
"""
|
|
Execute a backtest and return structured results.
|
|
|
|
Args:
|
|
request: BacktestRequest with strategy, symbol, and parameters
|
|
|
|
Returns:
|
|
BacktestResult with metrics, equity curve, and trades
|
|
"""
|
|
# Get strategy instance
|
|
strategy, default_params = get_strategy(request.strategy, is_grid=False)
|
|
|
|
# Merge default params with request params
|
|
params = {**default_params, **request.params}
|
|
|
|
# Convert market type string to enum
|
|
market_type = MarketType(request.market_type)
|
|
|
|
# Override strategy market type if specified
|
|
strategy.default_market_type = market_type
|
|
|
|
logger.info(
|
|
"Running backtest: %s on %s (%s), params=%s",
|
|
request.strategy, request.symbol, request.timeframe, params
|
|
)
|
|
|
|
# Execute backtest
|
|
result = self.bt.run_strategy(
|
|
strategy=strategy,
|
|
exchange_id=request.exchange,
|
|
symbol=request.symbol,
|
|
timeframe=request.timeframe,
|
|
start_date=request.start_date,
|
|
end_date=request.end_date,
|
|
init_cash=request.init_cash,
|
|
fees=request.fees,
|
|
slippage=request.slippage,
|
|
sl_stop=request.sl_stop,
|
|
tp_stop=request.tp_stop,
|
|
sl_trail=request.sl_trail,
|
|
leverage=request.leverage,
|
|
**params
|
|
)
|
|
|
|
# Extract data from portfolio
|
|
portfolio = result.portfolio
|
|
|
|
# Build trade records
|
|
trades = self._build_trade_records(portfolio)
|
|
|
|
# Build equity curve (trimmed to trading period)
|
|
equity_curve = self._build_equity_curve(portfolio)
|
|
|
|
# Build metrics
|
|
metrics = self._build_metrics(result, portfolio)
|
|
|
|
# Get date range from actual trading period (first trade to end)
|
|
idx = portfolio.wrapper.index
|
|
end_date = idx[-1].strftime("%Y-%m-%d %H:%M")
|
|
|
|
# Use first trade time as start if trades exist
|
|
trades_df = portfolio.trades.records_readable
|
|
if not trades_df.empty:
|
|
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
|
|
if first_entry_col in trades_df.columns:
|
|
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
|
|
start_date = first_trade_time.strftime("%Y-%m-%d %H:%M")
|
|
else:
|
|
start_date = idx[0].strftime("%Y-%m-%d %H:%M")
|
|
else:
|
|
start_date = idx[0].strftime("%Y-%m-%d %H:%M")
|
|
|
|
return BacktestResult(
|
|
run_id=str(uuid.uuid4()),
|
|
strategy=request.strategy,
|
|
symbol=request.symbol,
|
|
market_type=result.market_type.value,
|
|
timeframe=request.timeframe,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
leverage=result.leverage,
|
|
params=params,
|
|
metrics=metrics,
|
|
equity_curve=equity_curve,
|
|
trades=trades,
|
|
created_at=datetime.now(timezone.utc).isoformat(),
|
|
)
|
|
|
|
def _build_equity_curve(self, portfolio) -> list[EquityPoint]:
|
|
"""Extract equity curve with drawdown from portfolio, starting from first trade."""
|
|
value_series = portfolio.value()
|
|
drawdown_series = portfolio.drawdown()
|
|
|
|
# Handle multi-column case (from grid search)
|
|
if hasattr(value_series, 'columns') and len(value_series.columns) > 1:
|
|
value_series = value_series.iloc[:, 0]
|
|
drawdown_series = drawdown_series.iloc[:, 0]
|
|
elif hasattr(value_series, 'columns'):
|
|
value_series = value_series.iloc[:, 0]
|
|
drawdown_series = drawdown_series.iloc[:, 0]
|
|
|
|
# Find first trade time to trim equity curve
|
|
first_trade_idx = 0
|
|
trades_df = portfolio.trades.records_readable
|
|
if not trades_df.empty:
|
|
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
|
|
if first_entry_col in trades_df.columns:
|
|
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
|
|
# Find index in value_series closest to first trade
|
|
if hasattr(value_series.index, 'get_indexer'):
|
|
first_trade_idx = value_series.index.get_indexer([first_trade_time], method='nearest')[0]
|
|
# Start a few bars before first trade for context
|
|
first_trade_idx = max(0, first_trade_idx - 5)
|
|
|
|
# Slice from first trade onwards
|
|
value_series = value_series.iloc[first_trade_idx:]
|
|
drawdown_series = drawdown_series.iloc[first_trade_idx:]
|
|
|
|
points = []
|
|
for i, (ts, val) in enumerate(value_series.items()):
|
|
dd = drawdown_series.iloc[i] if i < len(drawdown_series) else 0.0
|
|
points.append(EquityPoint(
|
|
timestamp=ts.isoformat(),
|
|
value=float(val),
|
|
drawdown=float(dd) * 100, # Convert to percentage
|
|
))
|
|
|
|
return points
|
|
|
|
def _build_trade_records(self, portfolio) -> list[TradeRecord]:
|
|
"""Extract trade records from portfolio."""
|
|
trades_df = portfolio.trades.records_readable
|
|
|
|
if trades_df.empty:
|
|
return []
|
|
|
|
records = []
|
|
for _, row in trades_df.iterrows():
|
|
# Handle different column names in vectorbt
|
|
entry_time = row.get('Entry Timestamp', row.get('Entry Time', ''))
|
|
exit_time = row.get('Exit Timestamp', row.get('Exit Time', ''))
|
|
|
|
records.append(TradeRecord(
|
|
entry_time=str(entry_time) if pd.notna(entry_time) else "",
|
|
exit_time=str(exit_time) if pd.notna(exit_time) else None,
|
|
entry_price=float(row.get('Avg Entry Price', row.get('Entry Price', 0))),
|
|
exit_price=float(row.get('Avg Exit Price', row.get('Exit Price', 0)))
|
|
if pd.notna(row.get('Avg Exit Price', row.get('Exit Price'))) else None,
|
|
size=float(row.get('Size', 0)),
|
|
direction=str(row.get('Direction', 'Long')),
|
|
pnl=float(row.get('PnL', 0)) if pd.notna(row.get('PnL')) else None,
|
|
return_pct=float(row.get('Return', 0)) * 100
|
|
if pd.notna(row.get('Return')) else None,
|
|
status="closed" if pd.notna(exit_time) else "open",
|
|
))
|
|
|
|
return records
|
|
|
|
def _build_metrics(self, result, portfolio) -> BacktestMetrics:
|
|
"""Build metrics from backtest result."""
|
|
stats = portfolio.stats()
|
|
|
|
# Extract values, handling potential multi-column results
|
|
def get_stat(key: str, default: float = 0.0) -> float:
|
|
val = stats.get(key, default)
|
|
if hasattr(val, 'mean'):
|
|
return float(val.mean())
|
|
return float(val) if pd.notna(val) else default
|
|
|
|
total_return = portfolio.total_return()
|
|
if hasattr(total_return, 'mean'):
|
|
total_return = total_return.mean()
|
|
|
|
# Calculate benchmark return from first trade to end (not full period)
|
|
# This gives accurate comparison when strategy has training period
|
|
close = portfolio.close
|
|
benchmark_return = 0.0
|
|
|
|
if hasattr(close, 'iloc'):
|
|
# Find first trade entry time
|
|
trades_df = portfolio.trades.records_readable
|
|
if not trades_df.empty:
|
|
# Get the first trade entry timestamp
|
|
first_entry_col = 'Entry Timestamp' if 'Entry Timestamp' in trades_df.columns else 'Entry Time'
|
|
if first_entry_col in trades_df.columns:
|
|
first_trade_time = pd.to_datetime(trades_df[first_entry_col].iloc[0])
|
|
|
|
# Find the price at first trade
|
|
if hasattr(close.index, 'get_indexer'):
|
|
# Find closest index to first trade time
|
|
idx = close.index.get_indexer([first_trade_time], method='nearest')[0]
|
|
start_price = close.iloc[idx]
|
|
else:
|
|
start_price = close.iloc[0]
|
|
|
|
end_price = close.iloc[-1]
|
|
|
|
if hasattr(start_price, 'mean'):
|
|
start_price = start_price.mean()
|
|
if hasattr(end_price, 'mean'):
|
|
end_price = end_price.mean()
|
|
|
|
benchmark_return = ((end_price - start_price) / start_price)
|
|
else:
|
|
# No trades - use full period
|
|
start_price = close.iloc[0]
|
|
end_price = close.iloc[-1]
|
|
if hasattr(start_price, 'mean'):
|
|
start_price = start_price.mean()
|
|
if hasattr(end_price, 'mean'):
|
|
end_price = end_price.mean()
|
|
benchmark_return = ((end_price - start_price) / start_price)
|
|
|
|
# Alpha = strategy return - benchmark return
|
|
alpha = float(total_return) - float(benchmark_return)
|
|
|
|
sharpe = portfolio.sharpe_ratio()
|
|
if hasattr(sharpe, 'mean'):
|
|
sharpe = sharpe.mean()
|
|
|
|
max_dd = portfolio.max_drawdown()
|
|
if hasattr(max_dd, 'mean'):
|
|
max_dd = max_dd.mean()
|
|
|
|
win_rate = portfolio.trades.win_rate()
|
|
if hasattr(win_rate, 'mean'):
|
|
win_rate = win_rate.mean()
|
|
|
|
trade_count = portfolio.trades.count()
|
|
if hasattr(trade_count, 'mean'):
|
|
trade_count = int(trade_count.mean())
|
|
else:
|
|
trade_count = int(trade_count)
|
|
|
|
return BacktestMetrics(
|
|
total_return=float(total_return) * 100,
|
|
benchmark_return=float(benchmark_return) * 100,
|
|
alpha=float(alpha) * 100,
|
|
sharpe_ratio=float(sharpe) if pd.notna(sharpe) else 0.0,
|
|
max_drawdown=float(max_dd) * 100,
|
|
win_rate=float(win_rate) * 100 if pd.notna(win_rate) else 0.0,
|
|
total_trades=trade_count,
|
|
profit_factor=get_stat('Profit Factor'),
|
|
avg_trade_return=get_stat('Avg Winning Trade [%]'),
|
|
total_fees=get_stat('Total Fees Paid'),
|
|
total_funding=result.total_funding_paid,
|
|
liquidation_count=result.liquidation_count,
|
|
liquidation_loss=result.total_liquidation_loss,
|
|
adjusted_return=result.adjusted_return,
|
|
)
|
|
|
|
|
|
# Singleton instance
|
|
_runner: BacktestRunner | None = None
|
|
|
|
|
|
def get_runner() -> BacktestRunner:
|
|
"""Get or create the backtest runner instance."""
|
|
global _runner
|
|
if _runner is None:
|
|
_runner = BacktestRunner()
|
|
return _runner
|