Files
lowkey_backtest/api/services/runner.py
Simon Moisy 0c82c4f366 Implement FastAPI backend and Vue 3 frontend for Lowkey Backtest UI
- 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.
2026-01-14 21:44:04 +08:00

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