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.
This commit is contained in:
3
api/services/__init__.py
Normal file
3
api/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Business logic services for backtest execution and storage.
|
||||
"""
|
||||
300
api/services/runner.py
Normal file
300
api/services/runner.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
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
|
||||
225
api/services/storage.py
Normal file
225
api/services/storage.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Storage service for persisting and retrieving backtest runs.
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.models.database import BacktestRun
|
||||
from api.models.schemas import (
|
||||
BacktestResult,
|
||||
BacktestSummary,
|
||||
EquityPoint,
|
||||
BacktestMetrics,
|
||||
TradeRecord,
|
||||
)
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""
|
||||
Service for saving and loading backtest runs from SQLite.
|
||||
"""
|
||||
|
||||
def save_run(self, db: Session, result: BacktestResult) -> BacktestRun:
|
||||
"""
|
||||
Persist a backtest result to the database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
result: BacktestResult to save
|
||||
|
||||
Returns:
|
||||
Created BacktestRun record
|
||||
"""
|
||||
run = BacktestRun(
|
||||
run_id=result.run_id,
|
||||
strategy=result.strategy,
|
||||
symbol=result.symbol,
|
||||
market_type=result.market_type,
|
||||
timeframe=result.timeframe,
|
||||
leverage=result.leverage,
|
||||
params=result.params,
|
||||
start_date=result.start_date,
|
||||
end_date=result.end_date,
|
||||
total_return=result.metrics.total_return,
|
||||
benchmark_return=result.metrics.benchmark_return,
|
||||
alpha=result.metrics.alpha,
|
||||
sharpe_ratio=result.metrics.sharpe_ratio,
|
||||
max_drawdown=result.metrics.max_drawdown,
|
||||
win_rate=result.metrics.win_rate,
|
||||
total_trades=result.metrics.total_trades,
|
||||
profit_factor=result.metrics.profit_factor,
|
||||
total_fees=result.metrics.total_fees,
|
||||
total_funding=result.metrics.total_funding,
|
||||
liquidation_count=result.metrics.liquidation_count,
|
||||
liquidation_loss=result.metrics.liquidation_loss,
|
||||
adjusted_return=result.metrics.adjusted_return,
|
||||
)
|
||||
|
||||
# Serialize complex data
|
||||
run.set_equity_curve([p.model_dump() for p in result.equity_curve])
|
||||
run.set_trades([t.model_dump() for t in result.trades])
|
||||
|
||||
db.add(run)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
|
||||
return run
|
||||
|
||||
def get_run(self, db: Session, run_id: str) -> BacktestResult | None:
|
||||
"""
|
||||
Retrieve a backtest run by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
run_id: UUID of the run
|
||||
|
||||
Returns:
|
||||
BacktestResult or None if not found
|
||||
"""
|
||||
run = db.query(BacktestRun).filter(BacktestRun.run_id == run_id).first()
|
||||
|
||||
if not run:
|
||||
return None
|
||||
|
||||
return self._to_result(run)
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
db: Session,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
strategy: str | None = None,
|
||||
symbol: str | None = None,
|
||||
) -> tuple[list[BacktestSummary], int]:
|
||||
"""
|
||||
List backtest runs with optional filtering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
limit: Maximum number of runs to return
|
||||
offset: Offset for pagination
|
||||
strategy: Filter by strategy name
|
||||
symbol: Filter by symbol
|
||||
|
||||
Returns:
|
||||
Tuple of (list of summaries, total count)
|
||||
"""
|
||||
query = db.query(BacktestRun)
|
||||
|
||||
if strategy:
|
||||
query = query.filter(BacktestRun.strategy == strategy)
|
||||
if symbol:
|
||||
query = query.filter(BacktestRun.symbol == symbol)
|
||||
|
||||
total = query.count()
|
||||
|
||||
runs = query.order_by(BacktestRun.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
summaries = [self._to_summary(run) for run in runs]
|
||||
|
||||
return summaries, total
|
||||
|
||||
def get_runs_by_ids(self, db: Session, run_ids: list[str]) -> list[BacktestResult]:
|
||||
"""
|
||||
Retrieve multiple runs by their IDs.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
run_ids: List of run UUIDs
|
||||
|
||||
Returns:
|
||||
List of BacktestResults (preserves order)
|
||||
"""
|
||||
runs = db.query(BacktestRun).filter(BacktestRun.run_id.in_(run_ids)).all()
|
||||
|
||||
# Create lookup and preserve order
|
||||
run_map = {run.run_id: run for run in runs}
|
||||
results = []
|
||||
|
||||
for run_id in run_ids:
|
||||
if run_id in run_map:
|
||||
results.append(self._to_result(run_map[run_id]))
|
||||
|
||||
return results
|
||||
|
||||
def delete_run(self, db: Session, run_id: str) -> bool:
|
||||
"""
|
||||
Delete a backtest run.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
run_id: UUID of the run to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
run = db.query(BacktestRun).filter(BacktestRun.run_id == run_id).first()
|
||||
|
||||
if not run:
|
||||
return False
|
||||
|
||||
db.delete(run)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def _to_result(self, run: BacktestRun) -> BacktestResult:
|
||||
"""Convert database record to BacktestResult schema."""
|
||||
equity_data = run.get_equity_curve()
|
||||
trades_data = run.get_trades()
|
||||
|
||||
return BacktestResult(
|
||||
run_id=run.run_id,
|
||||
strategy=run.strategy,
|
||||
symbol=run.symbol,
|
||||
market_type=run.market_type,
|
||||
timeframe=run.timeframe,
|
||||
start_date=run.start_date or "",
|
||||
end_date=run.end_date or "",
|
||||
leverage=run.leverage,
|
||||
params=run.params or {},
|
||||
metrics=BacktestMetrics(
|
||||
total_return=run.total_return,
|
||||
benchmark_return=run.benchmark_return or 0.0,
|
||||
alpha=run.alpha or 0.0,
|
||||
sharpe_ratio=run.sharpe_ratio,
|
||||
max_drawdown=run.max_drawdown,
|
||||
win_rate=run.win_rate,
|
||||
total_trades=run.total_trades,
|
||||
profit_factor=run.profit_factor,
|
||||
total_fees=run.total_fees,
|
||||
total_funding=run.total_funding,
|
||||
liquidation_count=run.liquidation_count,
|
||||
liquidation_loss=run.liquidation_loss,
|
||||
adjusted_return=run.adjusted_return,
|
||||
),
|
||||
equity_curve=[EquityPoint(**p) for p in equity_data],
|
||||
trades=[TradeRecord(**t) for t in trades_data],
|
||||
created_at=run.created_at.isoformat() if run.created_at else "",
|
||||
)
|
||||
|
||||
def _to_summary(self, run: BacktestRun) -> BacktestSummary:
|
||||
"""Convert database record to BacktestSummary schema."""
|
||||
return BacktestSummary(
|
||||
run_id=run.run_id,
|
||||
strategy=run.strategy,
|
||||
symbol=run.symbol,
|
||||
market_type=run.market_type,
|
||||
timeframe=run.timeframe,
|
||||
total_return=run.total_return,
|
||||
sharpe_ratio=run.sharpe_ratio,
|
||||
max_drawdown=run.max_drawdown,
|
||||
total_trades=run.total_trades,
|
||||
created_at=run.created_at.isoformat() if run.created_at else "",
|
||||
params=run.params or {},
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_storage: StorageService | None = None
|
||||
|
||||
|
||||
def get_storage() -> StorageService:
|
||||
"""Get or create the storage service instance."""
|
||||
global _storage
|
||||
if _storage is None:
|
||||
_storage = StorageService()
|
||||
return _storage
|
||||
Reference in New Issue
Block a user